feat(api): add concil view
This commit is contained in:
@@ -1,12 +1,240 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-gradient-to-r from-purple-600 to-purple-800 rounded-2xl p-8 mb-8 text-white">
|
||||
<h1 class="text-3xl font-bold mb-2">Conseil de classe</h1>
|
||||
<p class="text-purple-100">Préparation et appréciations</p>
|
||||
</div>
|
||||
|
||||
<div class="card card-body text-center py-12">
|
||||
<p class="text-gray-500">Interface de conseil de classe à implémenter</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user