241 lines
8.9 KiB
Vue
241 lines
8.9 KiB
Vue
<template>
|
|
<div class="px-4 pt-2 pb-1 flex flex-col overflow-hidden" style="height: calc(100vh - 4rem)">
|
|
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
|
|
|
<template v-else-if="classData">
|
|
<!-- Compact toolbar: class info + trimester + stats on one line -->
|
|
<div v-if="currentStats" class="flex items-center gap-3 py-2 border-b border-gray-200 flex-shrink-0 flex-wrap">
|
|
<router-link :to="`/classes/${classData.id}`" class="text-gray-400 hover:text-gray-600 flex-shrink-0">
|
|
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
|
|
</router-link>
|
|
<span class="font-semibold text-gray-900 truncate">{{ classData.name }}</span>
|
|
<span class="text-xs text-gray-400 flex-shrink-0">{{ classData.year }}</span>
|
|
|
|
<div class="flex gap-1 flex-shrink-0">
|
|
<button
|
|
v-for="t in [1, 2, 3]"
|
|
:key="t"
|
|
@click="selectTrimester(t)"
|
|
class="px-2.5 py-1 rounded text-xs font-medium transition-colors"
|
|
:class="trimester === t
|
|
? 'bg-purple-600 text-white'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
|
>
|
|
T{{ t }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="border-l border-gray-200 h-5 flex-shrink-0"></div>
|
|
|
|
<div class="flex gap-4 text-xs flex-shrink-0">
|
|
<span><span class="text-gray-400">Moy</span> <span class="font-bold text-orange-600">{{ currentStats.mean?.toFixed(1) || '-' }}</span></span>
|
|
<span><span class="text-gray-400">Med</span> <span class="font-bold text-blue-600">{{ currentStats.median?.toFixed(1) || '-' }}</span></span>
|
|
<span class="hidden sm:inline"><span class="text-gray-400">σ</span> <span class="font-bold text-gray-700">{{ currentStats.std_dev?.toFixed(1) || '-' }}</span></span>
|
|
<span><span class="text-gray-400">Eleves</span> <span class="font-bold text-gray-900">{{ currentStats.students_count }}</span></span>
|
|
<span><span class="text-gray-400">Evals</span> <span class="font-bold text-gray-900">{{ currentStats.assessments_total }}</span></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main content: list + detail filling remaining space -->
|
|
<div v-if="rankedStudents.length" class="flex-1 flex flex-col lg:flex-row gap-3 min-h-0 pt-2">
|
|
<!-- Student list -->
|
|
<div class="w-full lg:w-64 xl:w-72 lg:flex-shrink-0 overflow-hidden">
|
|
<CouncilStudentList
|
|
:students="displayedStudents"
|
|
:selectedStudentId="selectedStudentId"
|
|
:classMean="currentStats?.mean ?? null"
|
|
v-model:search="searchQuery"
|
|
@select="selectStudent"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Student detail -->
|
|
<div class="flex-1 min-w-0 overflow-hidden">
|
|
<CouncilStudentDetail
|
|
v-if="selectedStudent"
|
|
:student="selectedStudent"
|
|
:classStats="currentStats"
|
|
:allTrimesterStats="allTrimesterStatsArray"
|
|
:currentTrimester="trimester"
|
|
:totalStudents="rankedStudents.length"
|
|
:hasPrev="selectedIndex > 0"
|
|
:hasNext="selectedIndex < displayedStudents.length - 1"
|
|
:classDomainsStats="currentStats?.domains_stats || []"
|
|
:classCompetencesStats="currentStats?.competences_stats || []"
|
|
@prev="navigateStudent(-1)"
|
|
@next="navigateStudent(1)"
|
|
/>
|
|
<div v-else class="bg-white rounded-xl shadow-md p-12 text-center">
|
|
<p class="text-gray-400">Selectionnez un eleve dans la liste</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div v-else-if="currentStats" class="flex-1 flex items-center justify-center">
|
|
<p class="text-gray-500">Aucune donnee disponible pour le trimestre {{ trimester }}</p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { useClassesStore } from '@/stores/classes'
|
|
import { classesService } from '@/services/classes'
|
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
|
import CouncilStudentList from '@/components/council/CouncilStudentList.vue'
|
|
import CouncilStudentDetail from '@/components/council/CouncilStudentDetail.vue'
|
|
|
|
const route = useRoute()
|
|
const classesStore = useClassesStore()
|
|
|
|
const loading = ref(true)
|
|
const classData = ref(null)
|
|
const trimester = ref(1)
|
|
const currentStats = ref(null)
|
|
const allTrimesterStats = ref({})
|
|
const selectedStudentId = ref(null)
|
|
const searchQuery = ref('')
|
|
|
|
// All students with rank + trend (unfiltered, used for global rank)
|
|
const rankedStudents = computed(() => {
|
|
if (!currentStats.value?.student_averages) return []
|
|
const students = currentStats.value.student_averages.map(s => ({ ...s }))
|
|
|
|
const withAvg = students.filter(s => s.average !== null)
|
|
withAvg.sort((a, b) => b.average - a.average)
|
|
withAvg.forEach((s, i) => { s.rank = i + 1 })
|
|
const withoutAvg = students.filter(s => s.average === null)
|
|
|
|
const prevTrimester = trimester.value > 1 ? allTrimesterStats.value[trimester.value - 1] : null
|
|
for (const s of students) {
|
|
if (prevTrimester) {
|
|
const prevStudent = prevTrimester.student_averages?.find(ps => ps.student_id === s.student_id)
|
|
if (prevStudent?.average != null && s.average !== null) {
|
|
const diff = s.average - prevStudent.average
|
|
s.trend = diff > 0.5 ? 'up' : diff < -0.5 ? 'down' : 'stable'
|
|
} else {
|
|
s.trend = null
|
|
}
|
|
} else {
|
|
s.trend = null
|
|
}
|
|
}
|
|
|
|
return [...withAvg, ...withoutAvg]
|
|
})
|
|
|
|
// Displayed students: filtered by search, sorted alphabetically
|
|
const displayedStudents = computed(() => {
|
|
let list = rankedStudents.value
|
|
if (searchQuery.value.trim()) {
|
|
const q = searchQuery.value.trim().toLowerCase()
|
|
list = list.filter(s =>
|
|
`${s.last_name} ${s.first_name}`.toLowerCase().includes(q)
|
|
)
|
|
}
|
|
return [...list].sort((a, b) => {
|
|
const nameA = `${a.last_name} ${a.first_name}`.toLowerCase()
|
|
const nameB = `${b.last_name} ${b.first_name}`.toLowerCase()
|
|
return nameA.localeCompare(nameB, 'fr')
|
|
})
|
|
})
|
|
|
|
watch(searchQuery, () => {
|
|
if (!displayedStudents.value.length) return
|
|
const still = displayedStudents.value.find(s => s.student_id === selectedStudentId.value)
|
|
if (!still) {
|
|
selectedStudentId.value = displayedStudents.value[0].student_id
|
|
}
|
|
})
|
|
|
|
const allTrimesterStatsArray = computed(() => {
|
|
const result = []
|
|
for (let t = 1; t <= 3; t++) {
|
|
const stats = allTrimesterStats.value[t]
|
|
if (stats) result.push({ trimester: t, stats })
|
|
}
|
|
return result
|
|
})
|
|
|
|
const selectedStudent = computed(() => {
|
|
if (selectedStudentId.value === null) return null
|
|
return displayedStudents.value.find(s => s.student_id === selectedStudentId.value) || null
|
|
})
|
|
|
|
const selectedIndex = computed(() => {
|
|
if (selectedStudentId.value === null) return -1
|
|
return displayedStudents.value.findIndex(s => s.student_id === selectedStudentId.value)
|
|
})
|
|
|
|
function selectStudent(id) {
|
|
selectedStudentId.value = id
|
|
}
|
|
|
|
function navigateStudent(delta) {
|
|
const newIndex = selectedIndex.value + delta
|
|
if (newIndex >= 0 && newIndex < displayedStudents.value.length) {
|
|
selectedStudentId.value = displayedStudents.value[newIndex].student_id
|
|
}
|
|
}
|
|
|
|
function handleKeydown(e) {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return
|
|
if (e.key === 'ArrowLeft') {
|
|
e.preventDefault()
|
|
navigateStudent(-1)
|
|
} else if (e.key === 'ArrowRight') {
|
|
e.preventDefault()
|
|
navigateStudent(1)
|
|
}
|
|
}
|
|
|
|
async function fetchData() {
|
|
loading.value = true
|
|
try {
|
|
const id = route.params.id
|
|
classData.value = await classesStore.fetchClass(id)
|
|
|
|
const promises = [1, 2, 3].map(t =>
|
|
classesService.getStats(id, t).then(data => ({ t, data })).catch(() => ({ t, data: null }))
|
|
)
|
|
const results = await Promise.all(promises)
|
|
for (const { t, data } of results) {
|
|
if (data) allTrimesterStats.value[t] = data
|
|
}
|
|
|
|
currentStats.value = allTrimesterStats.value[trimester.value] || null
|
|
|
|
if (displayedStudents.value.length && !selectedStudentId.value) {
|
|
selectedStudentId.value = displayedStudents.value[0].student_id
|
|
}
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function selectTrimester(t) {
|
|
trimester.value = t
|
|
currentStats.value = allTrimesterStats.value[t] || null
|
|
if (selectedStudentId.value) {
|
|
const stillExists = displayedStudents.value.find(s => s.student_id === selectedStudentId.value)
|
|
if (!stillExists && displayedStudents.value.length) {
|
|
selectedStudentId.value = displayedStudents.value[0].student_id
|
|
}
|
|
} else if (displayedStudents.value.length) {
|
|
selectedStudentId.value = displayedStudents.value[0].student_id
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchData()
|
|
window.addEventListener('keydown', handleKeydown)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('keydown', handleKeydown)
|
|
})
|
|
</script>
|