Files
notytex/frontend/src/views/CouncilView.vue
Bertrand Benjamin b1b7d12a9f
All checks were successful
Build and Publish Docker Images / Build Frontend Image (push) Successful in 3m25s
Build and Publish Docker Images / Build Backend Image (push) Successful in 3m35s
Build and Publish Docker Images / Build Summary (push) Successful in 4s
feat(api): add concil view
2026-02-12 17:18:59 +01:00

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">&sigma;</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>