feat(api): add concil view
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

This commit is contained in:
2026-02-12 17:18:59 +01:00
parent a30dd4af19
commit b1b7d12a9f
5 changed files with 812 additions and 17 deletions

View File

@@ -8,6 +8,7 @@
"name": "notytex-frontend",
"version": "2.0.0",
"dependencies": {
"@sgratzl/chartjs-chart-boxplot": "^4.4.5",
"axios": "^1.7.9",
"chart.js": "^4.4.7",
"pinia": "^2.2.6",
@@ -1143,6 +1144,24 @@
"win32"
]
},
"node_modules/@sgratzl/boxplots": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@sgratzl/boxplots/-/boxplots-2.0.0.tgz",
"integrity": "sha512-XHQTNTk0OtDEZyT7v+BKNKt4OEXYhn4GtfI+iQ1eFiFsx8Aq/csz+mOJYbfpkiP8Popd/8Nky8vLh1zzkYn0gw==",
"license": "MIT"
},
"node_modules/@sgratzl/chartjs-chart-boxplot": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/@sgratzl/chartjs-chart-boxplot/-/chartjs-chart-boxplot-4.4.5.tgz",
"integrity": "sha512-hvHcUIPIyzNPRmvPNIbA/R68zZKv7XHUcNrYJZTB8QsVE0KKuuGuEK98yCeCy32vZXQBl/PrHGOYMXeuqZrU0w==",
"license": "MIT",
"dependencies": {
"@sgratzl/boxplots": "^2.0.0"
},
"peerDependencies": {
"chart.js": "^4.1.1"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",

View File

@@ -10,20 +10,21 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^2.2.6",
"@sgratzl/chartjs-chart-boxplot": "^4.4.5",
"axios": "^1.7.9",
"chart.js": "^4.4.7",
"vue-chartjs": "^5.3.2"
"pinia": "^2.2.6",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.3",
"tailwindcss": "^3.4.17",
"postcss": "^8.4.49",
"autoprefixer": "^10.4.20",
"eslint": "^9.16.0",
"eslint-plugin-vue": "^9.31.0"
"eslint-plugin-vue": "^9.31.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.3"
}
}

View File

@@ -0,0 +1,473 @@
<template>
<div class="bg-white rounded-lg shadow-sm overflow-hidden h-full flex flex-col">
<!-- Compact header with inline navigation -->
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50 flex items-center gap-3 flex-shrink-0">
<button
@click="$emit('prev')"
:disabled="!hasPrev"
class="p-1 rounded hover:bg-gray-200 text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
>
<svg class="w-4 h-4" 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>
</button>
<div class="flex-1 flex items-center justify-between min-w-0">
<div class="min-w-0">
<span class="font-bold text-gray-900">{{ student.last_name }} {{ student.first_name }}</span>
<span class="text-xs text-gray-500 ml-2">
#{{ student.rank }}/{{ totalStudents }}
</span>
<span v-if="gapToMean !== null" class="text-xs ml-1" :class="gapToMean >= 0 ? 'text-green-600' : 'text-red-500'">
({{ gapToMean >= 0 ? '+' : '' }}{{ gapToMean.toFixed(1) }})
</span>
</div>
<div class="flex items-baseline gap-1 flex-shrink-0 ml-3">
<span class="text-2xl font-bold tabular-nums" :class="averageColor(student.average)">
{{ student.average !== null ? student.average.toFixed(2) : '-' }}
</span>
<span class="text-xs text-gray-400">/20</span>
</div>
</div>
<button
@click="$emit('next')"
:disabled="!hasNext"
class="p-1 rounded hover:bg-gray-200 text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
<!-- Content: 2-column grid on xl -->
<div class="flex-1 overflow-y-auto p-3">
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3">
<!-- Left column: chart + trimester summary -->
<div class="xl:col-span-3 space-y-2">
<!-- Chart -->
<section v-if="chartData.labels.length > 0">
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Parcours sur l'annee</h3>
<div class="bg-gray-50 rounded-lg p-2" style="height: 220px">
<Bar :key="student.student_id" :data="chartData" :options="chartOptions" />
</div>
<!-- Trimester summary: inline compact -->
<div class="flex gap-2 mt-2">
<div
v-for="tri in trimesterSummary"
:key="tri.trimester"
class="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded text-center"
:class="tri.trimester === currentTrimester ? 'bg-blue-50 ring-1 ring-blue-200' : 'bg-gray-50'"
>
<span class="text-[10px] text-gray-400">T{{ tri.trimester }}</span>
<span class="text-sm font-bold tabular-nums" :class="averageColor(tri.studentAvg)">
{{ tri.studentAvg !== null ? tri.studentAvg.toFixed(1) : '-' }}
</span>
<span class="text-[10px] text-gray-400">(cls {{ tri.classAvg !== null ? tri.classAvg.toFixed(1) : '-' }})</span>
<span v-if="tri.rank" class="text-[10px] text-gray-400">#{{ tri.rank }}</span>
<span v-if="tri.delta !== null" class="text-[10px]" :class="tri.delta > 0 ? 'text-green-600' : tri.delta < 0 ? 'text-red-500' : 'text-gray-400'">
{{ tri.delta > 0 ? '+' : '' }}{{ tri.delta.toFixed(1) }}
</span>
</div>
</div>
</section>
</div>
<!-- Right column: evaluations + domains + competences -->
<div class="xl:col-span-2 space-y-3">
<!-- Evaluations -->
<section v-if="assessmentList.length">
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Evaluations</h3>
<div class="space-y-0.5">
<div
v-for="a in assessmentList"
:key="a.assessment_id"
class="flex items-center justify-between py-1 px-2 rounded text-xs"
:class="assessmentBg(a)"
>
<span class="text-gray-700 truncate mr-1">{{ a.assessment_title }}</span>
<div class="flex items-center gap-1 flex-shrink-0">
<span class="font-semibold tabular-nums" :class="averageColor(a.score_on_20)">
{{ a.score !== null ? `${a.score.toFixed(1)}/${a.max_points.toFixed(0)}` : '-' }}
</span>
<span v-if="a.score_on_20 !== null" class="text-[10px] text-gray-400 tabular-nums">
({{ a.score_on_20.toFixed(1) }})
</span>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- Box plots section - full width below chart+evals grid -->
<div v-if="domainList.length || competenceList.length"
class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-3">
<section v-if="domainList.length">
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Domaines</h3>
<div class="bg-gray-50 rounded-lg p-2" :style="{ height: domainChartHeight }">
<ChartGeneric type="boxplot" :data="domainBoxPlotData" :options="boxPlotOptions" />
</div>
</section>
<section v-if="competenceList.length">
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Competences</h3>
<div class="bg-gray-50 rounded-lg p-2" :style="{ height: competenceChartHeight }">
<ChartGeneric type="boxplot" :data="competenceBoxPlotData" :options="boxPlotOptions" />
</div>
</section>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Bar, Chart as ChartGeneric } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend } from 'chart.js'
import { BoxPlotController, BoxAndWiskers } from '@sgratzl/chartjs-chart-boxplot'
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, BoxPlotController, BoxAndWiskers)
const props = defineProps({
student: { type: Object, required: true },
classStats: { type: Object, required: true },
allTrimesterStats: { type: Array, default: () => [] },
currentTrimester: { type: Number, required: true },
totalStudents: { type: Number, required: true },
hasPrev: { type: Boolean, default: false },
hasNext: { type: Boolean, default: false },
classDomainsStats: { type: Array, default: () => [] },
classCompetencesStats: { type: Array, default: () => [] }
})
defineEmits(['prev', 'next'])
const TRIMESTER_COLORS = {
1: 'rgba(99, 102, 241, 0.7)',
2: 'rgba(59, 130, 246, 0.7)',
3: 'rgba(139, 92, 246, 0.7)'
}
const gapToMean = computed(() => {
if (props.student.average === null || props.classStats.mean === null) return null
return props.student.average - props.classStats.mean
})
// Chart data
const chartData = computed(() => {
const labels = []
const studentScores = []
const classAvgs = []
const barBgColors = []
for (const { trimester, stats } of props.allTrimesterStats) {
const studentData = stats.student_averages?.find(s => s.student_id === props.student.student_id)
const refStudent = stats.student_averages?.find(s => s.assessment_scores && Object.keys(s.assessment_scores).length > 0)
if (!refStudent) continue
const assessments = Object.values(refStudent.assessment_scores)
.sort((a, b) => a.assessment_id - b.assessment_id)
for (const a of assessments) {
labels.push(`T${trimester}: ${a.assessment_title}`)
studentScores.push(studentData?.assessment_scores?.[a.assessment_id]?.score_on_20 ?? null)
barBgColors.push(TRIMESTER_COLORS[trimester] || TRIMESTER_COLORS[1])
let sum = 0, count = 0
for (const s of stats.student_averages) {
const score = s.assessment_scores?.[a.assessment_id]
if (score?.score_on_20 != null) { sum += score.score_on_20; count++ }
}
classAvgs.push(count > 0 ? sum / count : null)
}
}
return {
labels,
datasets: [
{
label: 'Eleve',
data: studentScores,
backgroundColor: barBgColors,
borderRadius: 3,
borderWidth: 0,
order: 2
},
{
label: 'Moyenne classe',
data: classAvgs,
type: 'line',
borderColor: 'rgba(249, 115, 22, 0.8)',
pointBackgroundColor: 'rgba(249, 115, 22, 0.8)',
borderWidth: 2,
pointRadius: 3,
fill: false,
tension: 0.1,
order: 1
}
]
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { boxWidth: 10, font: { size: 10 }, padding: 12 }
},
tooltip: {
callbacks: {
label(ctx) {
if (ctx.raw === null) return null
return `${ctx.dataset.label}: ${ctx.raw.toFixed(1)}/20`
}
}
}
},
scales: {
y: {
beginAtZero: true,
max: 20,
ticks: { stepSize: 5, font: { size: 9 } }
},
x: {
ticks: {
callback(value) {
const label = this.getLabelForValue(value)
return label.length > 18 ? label.slice(0, 16) + '...' : label
},
maxRotation: 35,
font: { size: 8 }
}
}
}
}
// Trimester summary
const trimesterSummary = computed(() => {
const summaries = []
let prevStudentAvg = null
for (const { trimester, stats } of props.allTrimesterStats) {
const studentData = stats.student_averages?.find(s => s.student_id === props.student.student_id)
const studentAvg = studentData?.average ?? null
let rank = null
if (studentAvg !== null) {
const withAvg = (stats.student_averages || []).filter(s => s.average !== null)
withAvg.sort((a, b) => b.average - a.average)
const idx = withAvg.findIndex(s => s.student_id === props.student.student_id)
if (idx >= 0) rank = idx + 1
}
const delta = (studentAvg !== null && prevStudentAvg !== null) ? studentAvg - prevStudentAvg : null
summaries.push({ trimester, studentAvg, classAvg: stats.mean ?? null, rank, delta })
prevStudentAvg = studentAvg
}
return summaries
})
// Assessment list (current trimester only)
const assessmentList = computed(() => {
if (!props.student.assessment_scores) return []
return Object.values(props.student.assessment_scores).sort((a, b) => a.assessment_id - b.assessment_id)
})
// Domain stats
const domainList = computed(() => {
if (!props.student.domain_stats) return []
const lookup = {}
for (const d of props.classDomainsStats) { lookup[d.id] = d }
return Object.values(props.student.domain_stats)
.filter(d => d.total_points_possible > 0)
.map(d => {
const meta = lookup[d.domain_id] || {}
return {
id: d.domain_id,
name: meta.name || `Domaine ${d.domain_id}`,
color: meta.color || '#6B7280',
pct: (d.total_points_obtained / d.total_points_possible) * 100
}
})
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
})
// Competence stats
const competenceList = computed(() => {
if (!props.student.competence_stats) return []
const lookup = {}
for (const c of props.classCompetencesStats) { lookup[c.id] = c }
return Object.values(props.student.competence_stats)
.filter(c => c.total_points_possible > 0)
.map(c => {
const meta = lookup[c.competence_id] || {}
return {
id: c.competence_id,
name: meta.name || `Competence ${c.competence_id}`,
color: meta.color || '#6B7280',
pct: (c.total_points_obtained / c.total_points_possible) * 100
}
})
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
})
// Domain distributions from all students in the class
const domainDistributions = computed(() => {
const distributions = {}
for (const student of props.classStats.student_averages || []) {
for (const [domainId, stats] of Object.entries(student.domain_stats || {})) {
if (stats.total_points_possible <= 0) continue
if (!distributions[domainId]) distributions[domainId] = []
distributions[domainId].push(
(stats.total_points_obtained / stats.total_points_possible) * 100
)
}
}
return distributions
})
// Competence distributions from all students in the class
const competenceDistributions = computed(() => {
const distributions = {}
for (const student of props.classStats.student_averages || []) {
for (const [compId, stats] of Object.entries(student.competence_stats || {})) {
if (stats.total_points_possible <= 0) continue
if (!distributions[compId]) distributions[compId] = []
distributions[compId].push(
(stats.total_points_obtained / stats.total_points_possible) * 100
)
}
}
return distributions
})
// Box plot data for domains
const domainBoxPlotData = computed(() => {
const domains = domainList.value
return {
labels: domains.map(d => d.name),
datasets: [
{
label: 'Classe',
data: domains.map(d => domainDistributions.value[d.id] || []),
backgroundColor: domains.map(d => `${d.color}33`),
borderColor: domains.map(d => d.color),
borderWidth: 1,
outlierRadius: 2,
itemRadius: 0,
meanRadius: 0
},
{
type: 'scatter',
label: 'Eleve',
data: domains.map((d, i) => ({ x: d.pct, y: i })),
pointRadius: 7,
pointStyle: 'rectRot',
pointBackgroundColor: 'rgba(239, 68, 68, 0.9)',
pointBorderColor: 'white',
pointBorderWidth: 2
}
]
}
})
// Box plot data for competences
const competenceBoxPlotData = computed(() => {
const comps = competenceList.value
return {
labels: comps.map(c => c.name),
datasets: [
{
label: 'Classe',
data: comps.map(c => competenceDistributions.value[c.id] || []),
backgroundColor: comps.map(c => `${c.color}33`),
borderColor: comps.map(c => c.color),
borderWidth: 1,
outlierRadius: 2,
itemRadius: 0,
meanRadius: 0
},
{
type: 'scatter',
label: 'Eleve',
data: comps.map((c, i) => ({ x: c.pct, y: i })),
pointRadius: 7,
pointStyle: 'rectRot',
pointBackgroundColor: 'rgba(239, 68, 68, 0.9)',
pointBorderColor: 'white',
pointBorderWidth: 2
}
]
}
})
const boxPlotOptions = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(ctx) {
if (ctx.dataset.type === 'scatter') {
return `Eleve: ${ctx.raw.x.toFixed(0)}%`
}
const item = ctx.raw
return [
`Min: ${item.min?.toFixed(0)}%`,
`Q1: ${item.q1?.toFixed(0)}%`,
`Med: ${item.median?.toFixed(0)}%`,
`Q3: ${item.q3?.toFixed(0)}%`,
`Max: ${item.max?.toFixed(0)}%`
]
}
}
}
},
scales: {
x: {
min: 0,
max: 100,
ticks: { callback: v => `${v}%`, font: { size: 9 } }
},
y: {
ticks: { font: { size: 10 } }
}
}
}
const domainChartHeight = computed(() =>
`${Math.max(100, domainList.value.length * 35 + 40)}px`
)
const competenceChartHeight = computed(() =>
`${Math.max(100, competenceList.value.length * 35 + 40)}px`
)
function assessmentBg(a) {
if (a.score_on_20 === null) return 'bg-gray-50'
const classAvg = getClassAssessmentAvg(a.assessment_id)
if (classAvg === null) return 'bg-gray-50'
if (a.score_on_20 >= classAvg + 0.5) return 'bg-green-50'
if (a.score_on_20 <= classAvg - 0.5) return 'bg-red-50'
return 'bg-gray-50'
}
function getClassAssessmentAvg(assessmentId) {
const students = props.classStats.student_averages
if (!students?.length) return null
let sum = 0, count = 0
for (const s of students) {
const score = s.assessment_scores?.[assessmentId]
if (score?.score_on_20 != null) { sum += score.score_on_20; count++ }
}
return count > 0 ? sum / count : null
}
function averageColor(avg) {
if (avg === null || avg === undefined) return 'text-gray-400'
if (avg < 10) return 'text-red-600'
if (avg >= 14) return 'text-green-600'
return 'text-gray-900'
}
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div class="bg-white rounded-lg shadow-sm overflow-hidden h-full flex flex-col">
<div class="px-3 py-2 border-b border-gray-200 flex-shrink-0">
<div class="relative">
<input
type="text"
:value="search"
@input="$emit('update:search', $event.target.value)"
placeholder="Rechercher..."
class="w-full text-xs border border-gray-300 rounded py-1.5 pl-7 pr-2 focus:ring-purple-500 focus:border-purple-500"
/>
<svg class="absolute left-2 top-2 h-3.5 w-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
<div class="overflow-y-auto flex-1">
<button
v-for="student in students"
:key="student.student_id"
@click="$emit('select', student.student_id)"
class="w-full text-left px-3 py-1.5 border-b border-gray-50 hover:bg-gray-50 transition-colors"
:class="student.student_id === selectedStudentId ? 'bg-blue-50 border-l-2 border-l-blue-500' : 'border-l-2 border-l-transparent'"
>
<div class="flex items-center justify-between gap-1">
<span class="text-xs font-medium text-gray-900 truncate">
{{ student.last_name }} {{ student.first_name }}
</span>
<div class="flex items-center gap-1 flex-shrink-0">
<span v-if="student.trend" class="text-[10px]" :class="trendClass(student.trend)">
{{ trendIcon(student.trend) }}
</span>
<span class="text-xs font-bold tabular-nums" :class="averageColor(student.average)">
{{ student.average !== null ? student.average.toFixed(1) : '-' }}
</span>
<span class="text-[10px] text-gray-400 tabular-nums w-7 text-right">
#{{ student.rank ?? '-' }}
</span>
</div>
</div>
</button>
</div>
</div>
</template>
<script setup>
defineProps({
students: { type: Array, required: true },
selectedStudentId: { type: Number, default: null },
classMean: { type: Number, default: null },
search: { type: String, default: '' }
})
defineEmits(['select', 'update:search'])
function averageColor(avg) {
if (avg === null) return 'text-gray-400'
if (avg < 10) return 'text-red-600'
if (avg >= 14) return 'text-green-600'
return 'text-gray-900'
}
function trendIcon(trend) {
if (trend === 'up') return '\u2197'
if (trend === 'down') return '\u2198'
return '\u2192'
}
function trendClass(trend) {
if (trend === 'up') return 'text-green-600'
if (trend === 'down') return 'text-red-500'
return 'text-gray-400'
}
</script>

View File

@@ -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">&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>