Files
notytex/templates/assessment_results.html

1058 lines
42 KiB
HTML

{% extends "base.html" %}
{% block title %}Résultats - {{ assessment.title }} - Gestion Scolaire{% endblock %}
{% block content %}
<div class="space-y-6">
<div class="flex justify-between items-center">
<div>
<a href="{{ url_for('assessments.detail', id=assessment.id) }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium mb-2 inline-block">
← Retour à l'évaluation
</a>
<h1 class="text-2xl font-bold text-gray-900">Résultats - {{ assessment.title }}</h1>
<p class="text-gray-600">{{ assessment.class_group.name }} - {{ assessment.date.strftime('%d/%m/%Y') }} - Trimestre {{ assessment.trimester }}</p>
</div>
</div>
<!-- Statistiques générales -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Statistiques de l'évaluation</h2>
</div>
<div class="px-6 py-4">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">{{ statistics.count }}</div>
<div class="text-sm text-gray-500">Élèves</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-green-600">{{ statistics.mean }}</div>
<div class="text-sm text-gray-500">Moyenne</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-purple-600">{{ statistics.median }}</div>
<div class="text-sm text-gray-500">Médiane</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-red-600">{{ statistics.min }}</div>
<div class="text-sm text-gray-500">Minimum</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-green-600">{{ statistics.max }}</div>
<div class="text-sm text-gray-500">Maximum</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-orange-600">{{ statistics.std_dev }}</div>
<div class="text-sm text-gray-500">Écart-type</div>
</div>
</div>
<div class="mt-4 text-sm text-gray-600 text-center">
Total des points de l'évaluation : {{ "%.1f"|format(total_max_points) }}
</div>
</div>
</div>
<!-- Bouton d'envoi des bilans -->
<div class="flex justify-end mb-6">
<button id="sendReportsBtn" onclick="openSendReportsModal()"
class="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
📧 Envoyer les bilans par email
</button>
</div>
<!-- Histogramme des notes -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Distribution des notes</h2>
</div>
<div class="px-6 py-4">
<canvas id="scoresChart" width="400" height="200"></canvas>
</div>
</div>
<!-- Heatmap par compétences -->
{% if heatmap_competences %}
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Performance par compétence</h2>
<p class="text-sm text-gray-600 mt-1">Heatmap des scores moyens de chaque élève par compétence</p>
</div>
<div class="px-6 py-4">
<div class="overflow-x-auto">
<canvas id="competencesHeatmap" width="800" height="400"></canvas>
</div>
</div>
</div>
{% endif %}
<!-- Heatmap par domaines -->
{% if heatmap_domains %}
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Performance par domaine</h2>
<p class="text-sm text-gray-600 mt-1">Heatmap des scores moyens de chaque élève par domaine</p>
</div>
<div class="px-6 py-4">
<div class="overflow-x-auto">
<canvas id="domainsHeatmap" width="800" height="400"></canvas>
</div>
</div>
</div>
{% endif %}
<!-- Heatmap par éléments de notation -->
{% if heatmap_grading_elements %}
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Performance par élément de notation</h2>
<p class="text-sm text-gray-600 mt-1">Heatmap détaillée des scores de chaque élève sur chaque question/compétence</p>
</div>
<div class="px-6 py-4">
<div class="overflow-x-auto">
<canvas id="gradingElementsHeatmap" width="1200" height="600"></canvas>
</div>
</div>
</div>
{% endif %}
<!-- Tableau des résultats -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Résultats par élève</h2>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Élève
</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Note totale
</th>
{% for exercise in assessment.exercises|sort(attribute='order') %}
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
{{ exercise.title }}
</th>
{% endfor %}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for student_data in students_scores %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
{{ student_data.student.last_name }} {{ student_data.student.first_name }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="text-sm font-bold text-gray-900">
{{ "%.1f"|format(student_data.total_score) }} / {{ "%.1f"|format(student_data.total_max_points) }}
</div>
</td>
{% for exercise in assessment.exercises|sort(attribute='order') %}
<td class="px-6 py-4 whitespace-nowrap text-center">
<div class="text-sm text-gray-900">
{% if exercise.id in student_data.exercises %}
{{ "%.1f"|format(student_data.exercises[exercise.id].score) }} / {{ "%.1f"|format(student_data.exercises[exercise.id].max_points) }}
{% else %}
-
{% endif %}
</div>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const ctx = document.getElementById('scoresChart').getContext('2d');
const scores = {{ scores_json | tojson }};
// Récupérer les données des élèves avec leurs noms et scores
const studentsData = [
{% for student_data in students_scores %}
{
name: "{{ student_data.student.last_name }} {{ student_data.student.first_name }}",
score: {{ student_data.total_score }}
}{% if not loop.last %},{% endif %}
{% endfor %}
];
// Créer les intervalles pour l'histogramme
const totalMaxPoints = {{ total_max_points }};
const minScore = 0;
const maxScore = totalMaxPoints;
const binWidth = 1; // Groupes de 1 point
const numBins = Math.ceil(maxScore - minScore); // Nombre de bins basé sur le total des points
const bins = [];
const labels = [];
const studentsInBins = []; // Nouveau: stocker les élèves pour chaque bin
for (let i = 0; i < numBins; i++) {
const binStart = minScore + i * binWidth;
const binEnd = minScore + (i + 1) * binWidth;
bins.push(0);
labels.push(`${binStart.toFixed(0)} - ${binEnd.toFixed(0)}`);
studentsInBins.push([]); // Initialiser la liste d'élèves pour ce bin
}
// Compter les scores dans chaque intervalle et stocker les noms des élèves
studentsData.forEach(student => {
let binIndex = Math.floor((student.score - minScore) / binWidth);
if (binIndex >= numBins) binIndex = numBins - 1; // Pour le score maximum
if (binIndex >= 0) {
bins[binIndex]++;
studentsInBins[binIndex].push(`${student.name} (${student.score.toFixed(1)})`);
}
});
new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Nombre d\'élèves',
data: bins,
backgroundColor: 'rgba(59, 130, 246, 0.5)',
borderColor: 'rgba(59, 130, 246, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: 'Distribution des notes'
},
legend: {
display: false
},
tooltip: {
callbacks: {
title: function(tooltipItems) {
const binIndex = tooltipItems[0].dataIndex;
return labels[binIndex];
},
label: function(tooltipItem) {
const binIndex = tooltipItem.dataIndex;
const count = bins[binIndex];
const students = studentsInBins[binIndex];
if (count === 0) {
return 'Aucun élève';
} else if (count === 1) {
return '1 élève : ' + students[0];
} else {
return `${count} élèves : ${students.join(', ')}`;
}
}
},
maxWidth: 400,
bodyFont: {
size: 12
},
titleFont: {
size: 13,
weight: 'bold'
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
},
title: {
display: true,
text: 'Nombre d\'élèves'
}
},
x: {
title: {
display: true,
text: 'Intervalles de notes'
}
}
}
}
});
// === HEATMAPS ===
// === HEATMAPS DATA ===
{% if heatmap_competences %}
const competencesData = {{ heatmap_competences | tojson }};
{% endif %}
{% if heatmap_domains %}
const domainsData = {{ heatmap_domains | tojson }};
{% endif %}
{% if heatmap_grading_elements %}
const gradingElementsData = {{ heatmap_grading_elements | tojson }};
{% endif %}
// Fonctions utilitaires pour l'interpolation HSL (globales pour être réutilisables)
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function rgbToHex(r, g, b) {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
function rgbToHsl(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0; // achromatic
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return { h: h * 360, s: s * 100, l: l * 100 };
}
function hslToRgb(h, s, l) {
h /= 360;
s /= 100;
l /= 100;
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255)
};
}
function interpolateColorHSL(color1, color2, factor) {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
if (!rgb1 || !rgb2) return color1;
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b);
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b);
// Interpolation en HSL
let h1 = hsl1.h;
let h2 = hsl2.h;
// Gérer la transition des teintes (éviter le long chemin autour du cercle)
let deltaH = h2 - h1;
if (deltaH > 180) {
h2 -= 360;
} else if (deltaH < -180) {
h2 += 360;
}
const h = h1 + (h2 - h1) * factor;
const s = hsl1.s + (hsl2.s - hsl1.s) * factor;
const l = hsl1.l + (hsl2.l - hsl1.l) * factor;
// Normaliser la teinte
const normalizedH = ((h % 360) + 360) % 360;
const rgb = hslToRgb(normalizedH, s, l);
return rgbToHex(rgb.r, rgb.g, rgb.b);
}
function createHeatmap(canvasId, data, type) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const students = data.students;
const categories = type === 'compétence' ? data.competences :
(type === 'domaine' ? data.domains : data.elements);
const scores = data.scores;
const colors = data.colors;
if (!categories || categories.length === 0) return;
// Dimensions adaptées selon le type
const padding = type === 'élément' ? 120 : 80; // Plus d'espace pour les libellés longs
const minCellWidth = type === 'élément' ? 50 : 60;
const minCellHeight = type === 'élément' ? 30 : 40; // Plus compact pour les éléments
const cellWidth = Math.max(minCellWidth, (canvas.width - padding * 2) / students.length);
const cellHeight = Math.max(minCellHeight, (canvas.height - padding * 2) / categories.length);
// Ajuster la taille du canvas si nécessaire
canvas.width = cellWidth * students.length + padding * 2;
canvas.height = cellHeight * categories.length + padding * 2;
// Effacer le canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = '12px Arial';
// Fonction pour convertir un score en couleur avec dégradé continu HSL
function getScoreColor(score) {
if (score === null || score === undefined) {
return '#f3f4f6'; // Gris clair pour les données manquantes
}
// Dégradé continu du rouge (0%) au vert (100%) en utilisant l'interpolation HSL
const percentage = Math.max(0, Math.min(100, score));
const factor = percentage / 100;
// Couleurs de début et fin (rouge vers vert)
const redColor = '#ef4444'; // Rouge (même que dans la config)
const greenColor = '#22c55e'; // Vert (même que dans la config)
return interpolateColorHSL(redColor, greenColor, factor);
}
// Dessiner les cellules
for (let studentIndex = 0; studentIndex < students.length; studentIndex++) {
for (let categoryIndex = 0; categoryIndex < categories.length; categoryIndex++) {
const score = scores[studentIndex][categoryIndex];
const x = padding + studentIndex * cellWidth;
const y = padding + categoryIndex * cellHeight;
// Couleur de fond basée sur le score
ctx.fillStyle = getScoreColor(score);
ctx.fillRect(x, y, cellWidth - 1, cellHeight - 1);
// Bordure
ctx.strokeStyle = '#d1d5db';
ctx.strokeRect(x, y, cellWidth - 1, cellHeight - 1);
// Texte du score
ctx.font = type === 'élément' ? '10px Arial' : '12px Arial'; // Police adaptée
if (score !== null && score !== undefined) {
ctx.fillStyle = score > 60 ? '#ffffff' : '#000000'; // Blanc sur foncé, noir sur clair
ctx.textAlign = 'center';
// Affichage simplifié pour les éléments (sans %)
const displayText = type === 'élément' ? score.toFixed(0) : score.toFixed(1) + '%';
ctx.fillText(displayText, x + cellWidth/2, y + cellHeight/2 + 4);
} else {
ctx.fillStyle = '#6b7280';
ctx.textAlign = 'center';
ctx.fillText('-', x + cellWidth/2, y + cellHeight/2 + 4);
}
// Remettre la police normale après
ctx.font = '12px Arial';
}
}
// Labels des étudiants (vertical, en haut)
ctx.fillStyle = '#374151';
ctx.textAlign = 'left';
for (let i = 0; i < students.length; i++) {
const x = padding + i * cellWidth + cellWidth/2;
const y = padding - 10;
ctx.save();
ctx.translate(x, y);
ctx.rotate(-Math.PI/4); // Rotation de -45 degrés
ctx.fillText(students[i], 0, 0);
ctx.restore();
}
// Labels des catégories (à gauche)
ctx.textAlign = 'right';
ctx.font = type === 'élément' ? '10px Arial' : '12px Arial'; // Police plus petite pour les éléments
for (let i = 0; i < categories.length; i++) {
const x = padding - 10;
const y = padding + i * cellHeight + cellHeight/2 + 4;
// Utiliser la couleur de la catégorie si disponible
const category = categories[i];
ctx.fillStyle = colors[category] || '#374151';
// Tronquer le texte si trop long pour les éléments
let displayText = category;
if (type === 'élément' && category.length > 25) {
displayText = category.substring(0, 22) + '...';
}
ctx.fillText(displayText, x, y);
}
// Légende (échelle de couleur continue)
const legendY = canvas.height - 40;
const legendHeight = 20;
const legendWidth = 200;
const legendX = (canvas.width - legendWidth) / 2;
// Dessiner la barre de légende avec dégradé continu
for (let i = 0; i <= 100; i++) {
const x = legendX + (i / 100) * legendWidth;
ctx.fillStyle = getScoreColor(i);
ctx.fillRect(x, legendY, 2, legendHeight);
}
// Bordure de la légende
ctx.strokeStyle = '#d1d5db';
ctx.strokeRect(legendX, legendY, legendWidth, legendHeight);
// Labels de la légende
ctx.fillStyle = '#374151';
ctx.textAlign = 'center';
ctx.fillText('0%', legendX, legendY + legendHeight + 15);
ctx.fillText('50%', legendX + legendWidth/2, legendY + legendHeight + 15);
ctx.fillText('100%', legendX + legendWidth, legendY + legendHeight + 15);
// Titre de la légende
ctx.fillText('Score moyen (%)', legendX + legendWidth/2, legendY - 10);
}
function createGradingElementsHeatmap(canvasId, data) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const students = data.students;
const elements = data.elements;
const detailedScores = data.detailed_scores;
const colors = data.colors;
const scaleColors = data.scale_colors;
if (!elements || elements.length === 0) return;
// Dimensions adaptées pour les éléments
const padding = 120;
const minCellWidth = 50;
const minCellHeight = 30;
const cellWidth = Math.max(minCellWidth, (canvas.width - padding * 2) / students.length);
const cellHeight = Math.max(minCellHeight, (canvas.height - padding * 2) / elements.length);
// Ajuster la taille du canvas si nécessaire
canvas.width = cellWidth * students.length + padding * 2;
canvas.height = cellHeight * elements.length + padding * 2;
// Effacer le canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = '10px Arial';
// Fonction pour obtenir la couleur selon le type et la valeur
function getGradeColor(gradeData) {
if (!gradeData) {
return '#f3f4f6'; // Gris clair pour les données manquantes
}
const { value, grading_type, max_points } = gradeData;
// Pour les valeurs spéciales définies en base (., d, a, etc.)
if (scaleColors[value]) {
return scaleColors[value].color;
}
if (grading_type === 'score') {
// Pour les scores (0-3), utiliser directement les couleurs de base
if (scaleColors[value]) {
return scaleColors[value].color;
}
// Fallback si la valeur n'existe pas dans l'échelle
return '#6b7280';
} else if (grading_type === 'notes') {
// Pour les notes numériques, calculer le pourcentage et utiliser un dégradé
try {
const numericValue = parseFloat(value.toString().replace(',', '.'));
const percentage = (numericValue / max_points) * 100;
// Utiliser le même dégradé HSL que les autres heatmaps pour les notes
const factor = Math.max(0, Math.min(100, percentage)) / 100;
return interpolateColorHSL('#ef4444', '#22c55e', factor);
} catch (e) {
return '#6b7280'; // Gris si erreur de parsing
}
}
return '#6b7280'; // Couleur par défaut
}
// Dessiner les cellules
for (let studentIndex = 0; studentIndex < students.length; studentIndex++) {
for (let elementIndex = 0; elementIndex < elements.length; elementIndex++) {
const gradeData = detailedScores[studentIndex][elementIndex];
const x = padding + studentIndex * cellWidth;
const y = padding + elementIndex * cellHeight;
// Couleur de fond basée sur la vraie valeur et le type
ctx.fillStyle = getGradeColor(gradeData);
ctx.fillRect(x, y, cellWidth - 1, cellHeight - 1);
// Bordure
ctx.strokeStyle = '#d1d5db';
ctx.strokeRect(x, y, cellWidth - 1, cellHeight - 1);
// Texte de la valeur originale
ctx.font = '10px Arial';
if (gradeData) {
// Déterminer la couleur du texte selon la luminosité du fond
const bgColor = getGradeColor(gradeData);
const isLightBackground = isColorLight(bgColor);
ctx.fillStyle = isLightBackground ? '#000000' : '#ffffff';
ctx.textAlign = 'center';
// Afficher la valeur originale (pas de conversion en %)
ctx.fillText(gradeData.value.toString(), x + cellWidth/2, y + cellHeight/2 + 4);
} else {
ctx.fillStyle = '#6b7280';
ctx.textAlign = 'center';
ctx.fillText('-', x + cellWidth/2, y + cellHeight/2 + 4);
}
}
}
// Labels des étudiants (vertical, en haut)
ctx.fillStyle = '#374151';
ctx.textAlign = 'left';
ctx.font = '12px Arial';
for (let i = 0; i < students.length; i++) {
const x = padding + i * cellWidth + cellWidth/2;
const y = padding - 10;
ctx.save();
ctx.translate(x, y);
ctx.rotate(-Math.PI/4);
ctx.fillText(students[i], 0, 0);
ctx.restore();
}
// Labels des éléments (à gauche)
ctx.textAlign = 'right';
ctx.font = '10px Arial';
for (let i = 0; i < elements.length; i++) {
const x = padding - 10;
const y = padding + i * cellHeight + cellHeight/2 + 4;
// Utiliser la couleur de l'élément si disponible
const element = elements[i];
ctx.fillStyle = colors[element] || '#374151';
// Tronquer le texte si trop long
let displayText = element;
if (element.length > 25) {
displayText = element.substring(0, 22) + '...';
}
ctx.fillText(displayText, x, y);
}
// Légende spéciale pour les éléments de notation
drawGradingElementsLegend(ctx, canvas, scaleColors);
}
// Fonction pour déterminer si une couleur est claire ou foncée
function isColorLight(hexColor) {
const rgb = hexToRgb(hexColor);
if (!rgb) return true;
// Calcul de la luminance relative
const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
return luminance > 0.5;
}
// Fonction pour dessiner la légende spécialisée
function drawGradingElementsLegend(ctx, canvas, scaleColors) {
const legendY = canvas.height - 80;
const legendItemHeight = 15;
const legendItemWidth = 15;
const legendSpacing = 5;
ctx.font = '11px Arial';
ctx.textAlign = 'left';
// Titre de la légende
ctx.fillStyle = '#374151';
ctx.fillText('Échelle de notation :', 20, legendY - 10);
let currentX = 20;
let currentY = legendY;
// Afficher les valeurs de l'échelle configurée
const sortedScaleItems = Object.entries(scaleColors).sort((a, b) => {
// Trier les valeurs numériques avant les valeurs spéciales
const aIsNum = !isNaN(parseInt(a[0]));
const bIsNum = !isNaN(parseInt(b[0]));
if (aIsNum && bIsNum) return parseInt(a[0]) - parseInt(b[0]);
if (aIsNum && !bIsNum) return -1;
if (!aIsNum && bIsNum) return 1;
return a[0].localeCompare(b[0]);
});
for (const [value, config] of sortedScaleItems) {
// Rectangle de couleur
ctx.fillStyle = config.color;
ctx.fillRect(currentX, currentY, legendItemWidth, legendItemHeight);
// Bordure
ctx.strokeStyle = '#d1d5db';
ctx.strokeRect(currentX, currentY, legendItemWidth, legendItemHeight);
// Texte
ctx.fillStyle = '#374151';
ctx.fillText(`${value}: ${config.label}`, currentX + legendItemWidth + legendSpacing, currentY + legendItemHeight - 3);
// Passer à la ligne après quelques éléments pour éviter le débordement
currentX += 120;
if (currentX > canvas.width - 150) {
currentX = 20;
currentY += legendItemHeight + legendSpacing;
}
}
// Note explicative pour les notes numériques
ctx.fillStyle = '#6b7280';
ctx.font = '10px Arial';
ctx.fillText('Notes numériques : dégradé rouge (0%) → vert (100%)', 20, currentY + 25);
}
// === APPELS AUX HEATMAPS (après toutes les définitions de fonctions) ===
{% if heatmap_competences %}
// Heatmap des compétences
createHeatmap('competencesHeatmap', competencesData, 'compétence');
{% endif %}
{% if heatmap_domains %}
// Heatmap des domaines
createHeatmap('domainsHeatmap', domainsData, 'domaine');
{% endif %}
{% if heatmap_grading_elements %}
// Heatmap des éléments de notation avec couleurs de base
createGradingElementsHeatmap('gradingElementsHeatmap', gradingElementsData);
{% endif %}
});
// === FONCTIONNALITÉ D'ENVOI DE BILANS ===
let eligibleStudents = [];
let selectedStudents = [];
// Charger les élèves éligibles au chargement de la page
document.addEventListener('DOMContentLoaded', function() {
loadEligibleStudents();
});
function loadEligibleStudents() {
fetch(`/assessments/{{ assessment.id }}/eligible-students`)
.then(response => response.json())
.then(data => {
if (data.success) {
eligibleStudents = data.students;
updateSendReportsButton(data.with_email_count, data.total_count);
} else {
console.error('Erreur lors du chargement des élèves:', data.error);
}
})
.catch(error => {
console.error('Erreur réseau:', error);
});
}
function updateSendReportsButton(withEmailCount, totalCount) {
const btn = document.getElementById('sendReportsBtn');
if (withEmailCount === 0) {
btn.disabled = true;
btn.classList.add('opacity-50', 'cursor-not-allowed');
btn.classList.remove('hover:bg-blue-700');
btn.innerHTML = `
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Aucune adresse email configurée
`;
} else {
btn.innerHTML = `
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
📧 Envoyer les bilans (${withEmailCount}/${totalCount} avec email)
`;
}
}
function openSendReportsModal() {
if (eligibleStudents.length === 0) {
alert('Aucun élève éligible trouvé');
return;
}
document.getElementById('sendReportsModal').classList.remove('hidden');
populateStudentsList();
}
function closeSendReportsModal() {
document.getElementById('sendReportsModal').classList.add('hidden');
selectedStudents = [];
}
function populateStudentsList() {
const container = document.getElementById('studentsList');
const studentsWithEmail = eligibleStudents.filter(s => s.has_email);
if (studentsWithEmail.length === 0) {
container.innerHTML = '<p class="text-gray-500 text-center py-4">Aucun élève avec adresse email configurée</p>';
return;
}
container.innerHTML = studentsWithEmail.map(student => `
<label class="flex items-center p-3 hover:bg-gray-50 rounded-lg cursor-pointer">
<input type="checkbox"
value="${student.id}"
onchange="updateSelectedStudents()"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-3">
<div class="flex-1">
<div class="font-medium text-gray-900">${student.full_name}</div>
<div class="text-sm text-gray-500">${student.email}</div>
</div>
</label>
`).join('');
// Sélectionner tous par défaut
selectAllStudents();
}
function selectAllStudents() {
const checkboxes = document.querySelectorAll('#studentsList input[type="checkbox"]');
checkboxes.forEach(cb => cb.checked = true);
updateSelectedStudents();
}
function unselectAllStudents() {
const checkboxes = document.querySelectorAll('#studentsList input[type="checkbox"]');
checkboxes.forEach(cb => cb.checked = false);
updateSelectedStudents();
}
function updateSelectedStudents() {
const checkboxes = document.querySelectorAll('#studentsList input[type="checkbox"]:checked');
selectedStudents = Array.from(checkboxes).map(cb => parseInt(cb.value));
const count = selectedStudents.length;
document.getElementById('selectedCount').textContent = count;
document.getElementById('sendReportsSubmitBtn').disabled = count === 0;
}
function sendReports() {
if (selectedStudents.length === 0) {
alert('Veuillez sélectionner au moins un élève');
return;
}
const customMessage = document.getElementById('customMessage').value.trim();
const submitBtn = document.getElementById('sendReportsSubmitBtn');
const originalText = submitBtn.innerHTML;
// Désactiver le bouton et afficher le loading
submitBtn.disabled = true;
submitBtn.innerHTML = `
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Envoi en cours...
`;
// Envoyer la requête
fetch(`/assessments/{{ assessment.id }}/send-reports`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
student_ids: selectedStudents,
custom_message: customMessage
})
})
.then(response => response.json())
.then(data => {
// Restaurer le bouton
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
if (data.success) {
// Afficher le résultat
showSendResult(data);
// Fermer la modal après un délai
setTimeout(() => {
closeSendReportsModal();
}, 3000);
} else {
alert(`Erreur lors de l'envoi: ${data.error}`);
}
})
.catch(error => {
// Restaurer le bouton en cas d'erreur
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
console.error('Erreur:', error);
alert('Erreur réseau lors de l\'envoi des bilans');
});
}
function showSendResult(result) {
const resultDiv = document.getElementById('sendResult');
let content = `<div class="mb-4">
<h3 class="text-lg font-medium ${result.success ? 'text-green-600' : 'text-red-600'} mb-2">
${result.message}
</h3>
</div>`;
if (result.sent_count > 0) {
content += `<div class="mb-3">
<p class="text-green-600">✅ ${result.sent_count} bilan(s) envoyé(s) avec succès</p>
</div>`;
}
if (result.error_count > 0) {
content += `<div class="mb-3">
<p class="text-red-600 font-medium">❌ ${result.error_count} erreur(s):</p>
<ul class="text-sm text-red-600 mt-2 max-h-32 overflow-y-auto">`;
result.errors.forEach(error => {
content += `<li class="py-1">• ${error}</li>`;
});
content += `</ul></div>`;
}
resultDiv.innerHTML = content;
resultDiv.classList.remove('hidden');
// Cacher les autres sections
document.getElementById('studentsSelection').classList.add('hidden');
document.getElementById('modalActions').classList.add('hidden');
}
// Fermer la modal en cliquant à côté
document.addEventListener('click', function(e) {
const modal = document.getElementById('sendReportsModal');
if (e.target === modal) {
closeSendReportsModal();
}
});
</script>
<!-- Modal d'envoi des bilans -->
<div id="sendReportsModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 max-w-2xl shadow-lg rounded-md bg-white">
<div class="mt-3">
<!-- En-tête -->
<div class="flex justify-between items-center pb-4 border-b">
<h3 class="text-lg font-medium text-gray-900">📧 Envoyer les bilans par email</h3>
<button onclick="closeSendReportsModal()" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Sélection des élèves -->
<div id="studentsSelection" class="py-4">
<div class="flex justify-between items-center mb-4">
<h4 class="font-medium text-gray-900">Sélectionner les élèves</h4>
<div class="flex space-x-2">
<button onclick="selectAllStudents()"
class="text-sm text-blue-600 hover:text-blue-800">Tout sélectionner</button>
<span class="text-gray-300">|</span>
<button onclick="unselectAllStudents()"
class="text-sm text-blue-600 hover:text-blue-800">Tout désélectionner</button>
</div>
</div>
<div id="studentsList" class="max-h-64 overflow-y-auto border border-gray-200 rounded-lg p-2 space-y-1">
<!-- Les élèves seront ajoutés ici par JavaScript -->
</div>
<p class="text-sm text-gray-600 mt-2">
<span id="selectedCount">0</span> élève(s) sélectionné(s)
</p>
</div>
<!-- Message personnalisé -->
<div class="py-4 border-t">
<label for="customMessage" class="block text-sm font-medium text-gray-700 mb-2">
Message personnalisé (optionnel)
</label>
<textarea id="customMessage"
rows="3"
placeholder="Message du professeur à ajouter dans les bilans..."
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<!-- Résultat de l'envoi -->
<div id="sendResult" class="hidden py-4 border-t">
<!-- Le résultat sera affiché ici -->
</div>
<!-- Actions -->
<div id="modalActions" class="flex justify-end space-x-3 pt-4 border-t">
<button onclick="closeSendReportsModal()"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Annuler
</button>
<button id="sendReportsSubmitBtn" onclick="sendReports()"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
disabled>
📧 Envoyer les bilans
</button>
</div>
</div>
</div>
</div>
{% endblock %}