feat(api): add concil view
This commit is contained in:
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
473
frontend/src/components/council/CouncilStudentDetail.vue
Normal file
473
frontend/src/components/council/CouncilStudentDetail.vue
Normal 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>
|
||||
74
frontend/src/components/council/CouncilStudentList.vue
Normal file
74
frontend/src/components/council/CouncilStudentList.vue
Normal 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>
|
||||
@@ -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