Files
notytex/frontend/src/views/ResultsView.vue
Bertrand Benjamin 6cca179346
All checks were successful
Build and Publish Docker Images / Build Frontend Image (push) Successful in 3m3s
Build and Publish Docker Images / Build Backend Image (push) Successful in 3m14s
Build and Publish Docker Images / Build Summary (push) Successful in 3s
refactor(ui): unify frontend around compact, desktop-first design
Extract shared utilities (color functions, icon registry), replace hero
banners with compact PageHeader, add TrimesterSelector/ConfirmDialog/
Breadcrumb components, consolidate off-palette colors to design tokens,
convert AssessmentListView to table layout, compress ResultsView stats
into horizontal bar, and inline ClassFormView as a modal in ClassListView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 09:37:46 +01:00

635 lines
24 KiB
Vue

<template>
<div class="px-4 sm:px-6 lg:px-8 py-8">
<LoadingSpinner v-if="loading" text="Chargement des résultats..." fullPage />
<template v-else-if="results">
<PageHeader
:title="results.assessment_title"
:subtitle="`Résultats \u00b7 ${gradedStudents.length}/${results.students_scores?.length || 0} élèves évalués`"
:breadcrumbs="[{ label: 'Évaluations', to: '/assessments' }, { label: results.assessment_title, to: `/assessments/${results.assessment_id}` }, { label: 'Résultats' }]"
/>
<!-- Statistics bar -->
<div class="flex items-center gap-6 px-4 py-3 bg-white rounded-lg border border-gray-200 mb-6 text-sm">
<div><span class="text-gray-500">Évalués</span> <span class="font-bold">{{ gradedStudents.length }}<span class="text-gray-400">/{{ results.students_scores?.length }}</span></span></div>
<div><span class="text-gray-500">Moy</span> <span class="font-bold">{{ calculatedStats?.mean?.toFixed(2) || '-' }}</span></div>
<div><span class="text-gray-500">Med</span> <span class="font-bold">{{ calculatedStats?.median?.toFixed(2) || '-' }}</span></div>
<div><span class="text-gray-500">E-T</span> <span class="font-bold">{{ calculatedStats?.std_dev?.toFixed(2) || '-' }}</span></div>
<div><span class="text-gray-500">Min</span> <span class="font-bold">{{ calculatedStats?.min?.toFixed(2) || '-' }}</span></div>
<div><span class="text-gray-500">Max</span> <span class="font-bold">{{ calculatedStats?.max?.toFixed(2) || '-' }}</span></div>
</div>
<!-- Avertissement si élèves non évalués -->
<div v-if="ungradedStudents.length > 0" class="mb-6 bg-amber-50 border border-amber-200 rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p class="text-sm font-medium text-amber-800">
{{ ungradedStudents.length }} élève(s) non évalué(s)
</p>
<p class="text-xs text-amber-700 mt-1">
Ces élèves n'ont aucune note et ne sont pas inclus dans les statistiques :
{{ ungradedStudents.map(s => s.student_name).join(', ') }}
</p>
</div>
</div>
</div>
<!-- Histogram with student names in tooltips -->
<div v-if="histogramData.labels.length > 0" class="card mb-8">
<div class="card-header">
<h2 class="text-lg font-semibold">Distribution des notes</h2>
</div>
<div class="card-body">
<div class="h-64">
<Bar :data="histogramData" :options="chartOptions" />
</div>
</div>
</div>
<!-- Heatmaps Section - Tables HTML -->
<div v-if="results.competences_heatmap || results.domains_heatmap" class="space-y-6 mb-8">
<!-- Heatmap Compétences -->
<div v-if="results.competences_heatmap && results.competences_heatmap.items.length > 0" class="card">
<div class="card-header">
<h2 class="text-lg font-semibold">Performance par compétences</h2>
</div>
<div class="card-body overflow-x-auto">
<table class="min-w-full text-xs">
<thead>
<tr>
<th class="px-2 py-1 text-left font-medium text-gray-700 bg-gray-50 sticky left-0">Compétence</th>
<th
v-for="student in results.competences_heatmap.students"
:key="student"
class="px-1 py-1 text-center font-medium text-gray-600 bg-gray-50 min-w-[40px]"
>
<div class="transform -rotate-45 origin-center whitespace-nowrap text-[10px]">
{{ truncateName(student) }}
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in results.competences_heatmap.items" :key="item">
<td class="px-2 py-1 font-medium text-gray-700 bg-gray-50 sticky left-0 whitespace-nowrap">
{{ truncateLabel(item) }}
</td>
<td
v-for="student in results.competences_heatmap.students"
:key="`${item}-${student}`"
class="px-1 py-1 text-center"
:style="{ backgroundColor: getCellColor(getHeatmapCell(results.competences_heatmap, student, item)) }"
:title="`${student}: ${getHeatmapCell(results.competences_heatmap, student, item)?.percentage?.toFixed(0) || 0}%`"
>
<span class="text-[10px]" :class="getCellTextClass(getHeatmapCell(results.competences_heatmap, student, item))">
{{ getHeatmapCell(results.competences_heatmap, student, item)?.percentage?.toFixed(0) || '-' }}
</span>
</td>
</tr>
</tbody>
</table>
<!-- Légende -->
<div class="mt-4 flex items-center justify-center space-x-2">
<span class="text-xs text-gray-500">0%</span>
<div class="h-3 w-48 bg-gradient-to-r from-red-500 via-yellow-500 to-green-500 rounded"></div>
<span class="text-xs text-gray-500">100%</span>
</div>
</div>
</div>
<!-- Heatmap Domaines -->
<div v-if="results.domains_heatmap && results.domains_heatmap.items.length > 0" class="card">
<div class="card-header">
<h2 class="text-lg font-semibold">Performance par domaines</h2>
</div>
<div class="card-body overflow-x-auto">
<table class="min-w-full text-xs">
<thead>
<tr>
<th class="px-2 py-1 text-left font-medium text-gray-700 bg-gray-50 sticky left-0">Domaine</th>
<th
v-for="student in results.domains_heatmap.students"
:key="student"
class="px-1 py-1 text-center font-medium text-gray-600 bg-gray-50 min-w-[40px]"
>
<div class="transform -rotate-45 origin-center whitespace-nowrap text-[10px]">
{{ truncateName(student) }}
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in results.domains_heatmap.items" :key="item">
<td class="px-2 py-1 font-medium text-gray-700 bg-gray-50 sticky left-0 whitespace-nowrap">
{{ truncateLabel(item) }}
</td>
<td
v-for="student in results.domains_heatmap.students"
:key="`${item}-${student}`"
class="px-1 py-1 text-center"
:style="{ backgroundColor: getCellColor(getHeatmapCell(results.domains_heatmap, student, item)) }"
:title="`${student}: ${getHeatmapCell(results.domains_heatmap, student, item)?.percentage?.toFixed(0) || 0}%`"
>
<span class="text-[10px]" :class="getCellTextClass(getHeatmapCell(results.domains_heatmap, student, item))">
{{ getHeatmapCell(results.domains_heatmap, student, item)?.percentage?.toFixed(0) || '-' }}
</span>
</td>
</tr>
</tbody>
</table>
<!-- Légende -->
<div class="mt-4 flex items-center justify-center space-x-2">
<span class="text-xs text-gray-500">0%</span>
<div class="h-3 w-48 bg-gradient-to-r from-red-500 via-yellow-500 to-green-500 rounded"></div>
<span class="text-xs text-gray-500">100%</span>
</div>
</div>
</div>
</div>
<!-- Student scores table -->
<div class="card">
<div class="card-header flex justify-between items-center">
<h2 class="text-lg font-semibold">Détail par élève</h2>
<button
v-if="gradedStudentsWithEmail.length > 0"
@click="toggleSelectAll"
class="btn btn-sm btn-secondary"
>
{{ allSelected ? 'Tout désélectionner' : 'Tout sélectionner' }}
</button>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th v-if="gradedStudentsWithEmail.length > 0" class="w-16">
<div class="flex items-center gap-1">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</th>
<th>Élève</th>
<th>Email</th>
<th class="text-center">Score</th>
<th class="text-center">%</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr
v-for="student in results.students_scores"
:key="student.student_id"
:class="{
'bg-gray-50 opacity-60': !isStudentGraded(student),
'bg-blue-50 border-l-4 border-blue-500': isSelected(student.student_id)
}"
>
<td v-if="gradedStudentsWithEmail.length > 0" class="w-16">
<input
v-if="isStudentGraded(student) && student.email"
type="checkbox"
:value="student.student_id"
v-model="selectedStudents"
class="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
/>
<span
v-else-if="isStudentGraded(student) && !student.email"
class="text-amber-500"
title="Pas d'adresse email"
>
⚠️
</span>
</td>
<td class="font-medium">
{{ student.student_name }}
<span v-if="!isStudentGraded(student)" class="ml-2 text-xs text-amber-600">(non évalué)</span>
</td>
<td class="text-sm text-gray-600">
<span v-if="student.email">{{ student.email }}</span>
<span v-else class="text-gray-400 italic">Pas d'email</span>
</td>
<td class="text-center font-bold">
<template v-if="isStudentGraded(student)">
<span
v-if="configStore.notesGradient.enabled"
class="px-2 py-1 rounded"
:style="{
backgroundColor: getGradientColor(student.percentage),
color: getTextColorForBg(getGradientColor(student.percentage) || '#ffffff')
}"
>
{{ student.total_score?.toFixed(1) }}/{{ student.total_max_points }}
</span>
<span v-else>
{{ student.total_score?.toFixed(1) }}/{{ student.total_max_points }}
</span>
</template>
<span v-else class="text-gray-400">-</span>
</td>
<td class="text-center">
<template v-if="isStudentGraded(student)">
<span
v-if="configStore.notesGradient.enabled"
class="px-2 py-1 rounded text-sm font-medium"
:style="{
backgroundColor: getGradientColor(student.percentage),
color: getTextColorForBg(getGradientColor(student.percentage) || '#ffffff')
}"
>
{{ student.percentage?.toFixed(0) }}%
</span>
<span
v-else
class="badge"
:class="getPercentageClass(student.percentage)"
>
{{ student.percentage?.toFixed(0) }}%
</span>
</template>
<span v-else class="text-gray-400">-</span>
</td>
<td class="text-center">
<button
v-if="isStudentGraded(student)"
@click="openReportInNewTab(student.student_id)"
class="btn btn-sm btn-secondary"
title="Ouvrir le bilan dans un nouvel onglet"
>
👁️ Aperçu
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<!-- Sticky bottom bar for sending emails -->
<Transition name="slide-up">
<div
v-if="selectedStudents.length > 0"
class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg z-40 px-6 py-3"
>
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm text-gray-600">
<strong>{{ selectedStudents.length }}</strong> élève{{ selectedStudents.length > 1 ? 's' : '' }} sélectionné{{ selectedStudents.length > 1 ? 's' : '' }}
</span>
<button @click="selectedStudents = []" class="text-sm text-gray-500 hover:text-gray-700">
Effacer
</button>
</div>
<button
@click="openSendModal"
class="btn btn-primary"
>
Envoyer les bilans à {{ selectedStudents.length }} élève{{ selectedStudents.length > 1 ? 's' : '' }}
</button>
</div>
</div>
</Transition>
<!-- Modal d'envoi de bilans -->
<SendReportsModal
v-if="showSendModal"
:assessment-id="results.assessment_id"
:selected-students="selectedStudentsData"
@close="showSendModal = false"
@sent="handleReportsSent"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useAssessmentsStore } from '@/stores/assessments'
import { useConfigStore } from '@/stores/config'
import { Bar } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import SendReportsModal from '@/components/assessment/SendReportsModal.vue'
import PageHeader from '@/components/common/PageHeader.vue'
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const route = useRoute()
const assessmentsStore = useAssessmentsStore()
const configStore = useConfigStore()
// État pour la sélection d'élèves et l'envoi d'emails
const selectedStudents = ref([])
const showSendModal = ref(false)
import { interpolateColorHSL, getTextColorForBg } from '@/utils/colors'
// Get gradient color based on percentage
function getGradientColor(percentage) {
const gradient = configStore.notesGradient
if (!gradient.enabled) {
return null
}
const factor = Math.max(0, Math.min(1, percentage / 100))
return interpolateColorHSL(gradient.min_color, gradient.max_color, factor)
}
const loading = ref(true)
const results = ref(null)
// Filtrer les élèves qui ont été évalués (au moins une note)
const gradedStudents = computed(() => {
const scores = results.value?.students_scores || []
// Utiliser has_grades du backend qui indique si l'élève a au moins une note non-vide
return scores.filter(s => s.has_grades === true)
})
// Élèves non corrigés
const ungradedStudents = computed(() => {
const scores = results.value?.students_scores || []
return scores.filter(s => s.has_grades !== true)
})
// Statistiques recalculées uniquement sur les élèves corrigés
const calculatedStats = computed(() => {
const scores = gradedStudents.value
if (scores.length === 0) return null
const values = scores.map(s => s.total_score || 0)
const sorted = [...values].sort((a, b) => a - b)
const sum = values.reduce((acc, v) => acc + v, 0)
const mean = sum / values.length
const median = values.length % 2 === 0
? (sorted[values.length / 2 - 1] + sorted[values.length / 2]) / 2
: sorted[Math.floor(values.length / 2)]
const variance = values.reduce((acc, v) => acc + Math.pow(v - mean, 2), 0) / values.length
const std_dev = Math.sqrt(variance)
return {
mean,
median,
std_dev,
min: sorted[0],
max: sorted[sorted.length - 1],
count: values.length
}
})
// Créer les bins avec les noms des élèves (uniquement corrigés)
const studentsInBins = computed(() => {
const scores = gradedStudents.value
if (scores.length === 0) return []
const maxScore = Math.max(...scores.map(s => s.total_max_points || 20))
const bins = []
for (let i = 0; i <= maxScore; i++) {
bins.push([])
}
scores.forEach(s => {
const bin = Math.floor(s.total_score || 0)
if (bin >= 0 && bin < bins.length) {
bins[bin].push(s.student_name)
}
})
return bins
})
const histogramData = computed(() => {
const scores = gradedStudents.value
if (scores.length === 0) return { labels: [], datasets: [] }
const maxScore = Math.max(...scores.map(s => s.total_max_points || 20))
const bins = []
const labels = []
const colors = []
const gradient = configStore.notesGradient
for (let i = 0; i <= maxScore; i++) {
bins.push(0)
labels.push(i.toString())
// Calculer la couleur pour chaque barre
if (gradient.enabled) {
const factor = i / maxScore
colors.push(interpolateColorHSL(gradient.min_color, gradient.max_color, factor))
} else {
colors.push('#3b82f6')
}
}
scores.forEach(s => {
const bin = Math.floor(s.total_score || 0)
if (bin >= 0 && bin < bins.length) {
bins[bin]++
}
})
return {
labels,
datasets: [{
label: 'Nombre d\'élèves',
data: bins,
backgroundColor: colors
}]
}
})
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
const binIndex = context.dataIndex
const count = context.raw
const students = studentsInBins.value[binIndex] || []
if (count === 0) {
return '0 élève'
} else if (count === 1) {
return `1 élève : ${students[0]}`
} else {
return `${count} élèves : ${students.join(', ')}`
}
}
},
maxWidth: 400
}
},
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1 }
}
}
}))
// Fonctions utilitaires pour les heatmaps
function getHeatmapCell(heatmapData, studentName, itemName) {
if (!heatmapData?.cells) return null
return heatmapData.cells.find(c => c.student_name === studentName && c.item_name === itemName)
}
function getCellColor(cell) {
if (!cell) return '#f3f4f6' // gray-100
const percentage = cell.percentage || 0
// Utiliser le dégradé configurable si activé
const gradient = configStore.notesGradient
if (gradient.enabled) {
const factor = Math.max(0, Math.min(1, percentage / 100))
return interpolateColorHSL(gradient.min_color, gradient.max_color, factor)
}
// Fallback: Interpolation de couleur rouge -> jaune -> vert
if (percentage <= 50) {
// Rouge vers jaune
const factor = percentage / 50
const r = 239
const g = Math.round(68 + (158 - 68) * factor)
const b = Math.round(68 + (11 - 68) * factor)
return `rgb(${r}, ${g}, ${b})`
} else {
// Jaune vers vert
const factor = (percentage - 50) / 50
const r = Math.round(245 - (245 - 34) * factor)
const g = Math.round(158 + (197 - 158) * factor)
const b = Math.round(11 + (94 - 11) * factor)
return `rgb(${r}, ${g}, ${b})`
}
}
function getCellTextClass(cell) {
if (!cell) return 'text-gray-400'
return cell.percentage > 50 ? 'text-white font-medium' : 'text-gray-800 font-medium'
}
function truncateName(name) {
if (!name) return ''
// Prendre juste le nom de famille + initiale prénom
const parts = name.split(' ')
if (parts.length >= 2) {
return `${parts[0]} ${parts[1][0]}.`
}
return name.length > 8 ? name.substring(0, 8) + '...' : name
}
function truncateLabel(label) {
if (!label) return ''
return label.length > 20 ? label.substring(0, 20) + '...' : label
}
function getPercentageClass(percentage) {
if (percentage >= 80) return 'badge-success'
if (percentage >= 60) return 'badge-primary'
if (percentage >= 40) return 'badge-warning'
return 'badge-danger'
}
function isStudentGraded(student) {
return student.has_grades === true
}
// Computed pour les élèves corrigés avec email
const gradedStudentsWithEmail = computed(() => {
return gradedStudents.value.filter(s => s.email)
})
// Vérifie si tous les élèves sont sélectionnés
const allSelected = computed(() => {
if (gradedStudentsWithEmail.value.length === 0) return false
return gradedStudentsWithEmail.value.every(s => selectedStudents.value.includes(s.student_id))
})
// Vérifie s'il y a au moins un élève avec email
const hasStudentsWithEmail = computed(() => {
return gradedStudentsWithEmail.value.length > 0
})
// Données des élèves sélectionnés pour le modal
const selectedStudentsData = computed(() => {
if (!results.value) return []
return results.value.students_scores
.filter(s => selectedStudents.value.includes(s.student_id))
.map(s => ({
id: s.student_id,
name: s.student_name,
email: s.email
}))
})
// Vérifier si un élève est sélectionné
function isSelected(studentId) {
return selectedStudents.value.includes(studentId)
}
// Toggle sélection de tous les élèves
function toggleSelectAll() {
if (allSelected.value) {
selectedStudents.value = []
} else {
selectedStudents.value = gradedStudentsWithEmail.value.map(s => s.student_id)
}
}
// Ouvrir le modal d'envoi
function openSendModal() {
if (selectedStudents.value.length === 0) return
showSendModal.value = true
}
// Gérer le résultat de l'envoi
function handleReportsSent(result) {
showSendModal.value = false
selectedStudents.value = []
// Le modal affiche déjà les résultats, pas besoin de notification supplémentaire
}
// Ouvrir l'aperçu d'un bilan dans un nouvel onglet
function openReportInNewTab(studentId) {
const assessmentId = results.value.assessment_id
// Construire l'URL complète avec le préfixe API
const baseUrl = window.location.origin // Ex: http://localhost:5173
const url = `${baseUrl}/api/v2/assessments/${assessmentId}/preview-report/${studentId}`
window.open(url, '_blank')
}
onMounted(async () => {
try {
// Charger la config du dégradé si pas déjà chargée
if (!configStore.notesGradient.min_color) {
await configStore.loadNotesGradient()
}
results.value = await assessmentsStore.fetchResults(route.params.id)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
}
</style>