Files
notytex/templates/assessment_grading.html

1042 lines
47 KiB
HTML

{% extends "base.html" %}
{% block title %}Saisie des notes - {{ assessment.title }} - Gestion Scolaire{% endblock %}
{% block content %}
<!-- Panneau d'aide raccourcis clavier -->
<div id="keyboard-help" class="fixed top-4 right-4 bg-gray-800 text-white p-4 rounded-lg shadow-lg z-50 hidden max-w-sm">
<div class="flex justify-between items-start mb-3">
<h3 class="font-semibold text-sm">Raccourcis clavier</h3>
<button onclick="toggleKeyboardHelp()" class="text-gray-300 hover:text-white">
<svg class="w-4 h-4" 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"></path>
</svg>
</button>
</div>
<div class="text-xs space-y-1">
<div><kbd class="bg-gray-700 px-1 rounded">Tab</kbd> / <kbd class="bg-gray-700 px-1 rounded">Shift+Tab</kbd> : Navigation</div>
<div><kbd class="bg-gray-700 px-1 rounded">Enter</kbd> : Champ suivant (même colonne)</div>
<div><kbd class="bg-gray-700 px-1 rounded">.</kbd> : Non évalué (scores)</div>
<div><kbd class="bg-gray-700 px-1 rounded">Ctrl+S</kbd> : Sauvegarder</div>
<div><kbd class="bg-gray-700 px-1 rounded">Ctrl+Z</kbd> : Annuler dernière saisie</div>
<div><kbd class="bg-gray-700 px-1 rounded">Échap</kbd> : Vider le champ actuel</div>
<div><kbd class="bg-gray-700 px-1 rounded">F1</kbd> : Afficher/Masquer cette aide</div>
</div>
</div>
<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">Saisie des notes</h1>
<p class="text-gray-600">{{ assessment.title }} - {{ assessment.class_group.name }}</p>
</div>
</div>
{% if not grading_elements %}
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">Aucun élément de notation</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>Cette évaluation n'a pas encore d'éléments de notation configurés. Vous devez d'abord créer des exercices et leurs éléments de notation.</p>
</div>
<div class="mt-4">
<a href="{{ url_for('assessments.detail', id=assessment.id) }}" class="text-sm font-medium text-yellow-800 underline hover:text-yellow-900">
Configurer l'évaluation →
</a>
</div>
</div>
</div>
</div>
{% else %}
<form method="POST" action="{{ url_for('grading.save_grades', assessment_id=assessment.id) }}" class="space-y-6" id="grading-form">
<!-- Guide de saisie unifié moderne -->
<div class="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-lg p-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="text-sm font-semibold text-blue-900 mb-2">Guide de saisie unifié</h3>
<div class="grading-guide text-xs text-blue-800">
<span><strong>Notes :</strong> Valeurs décimales (ex: 15.5)</span>
<span class="mx-3"></span>
<span><strong>Scores :</strong>
{% for value in ['0', '1', '2', '3'] %}
{% if value in scale_values %}
{% set config = scale_values[value] %}
<span style="color: {{ config.color }}; font-weight: bold;">{{ value }}={{ config.label }}</span>
{% if not loop.last %}, {% endif %}
{% endif %}
{% endfor %}
</span>
<span class="mx-3"></span>
<span><strong>Spéciaux :</strong>
{% set special_items = [] %}
{% for value in scale_values.keys() if not value.isdigit() %}
{% if value in scale_values %}
{% set config = scale_values[value] %}
{% set item = '<kbd class="bg-gray-200 px-1 rounded text-xs" style="color: ' + config.color + '; font-weight: bold;">' + value + '</kbd><span style="color: ' + config.color + ';">=' + config.label + '</span>' %}
{% set _ = special_items.append(item) %}
{% endif %}
{% endfor %}
{{ special_items | join(', ') | safe }}
</span>
</div>
</div>
<div class="text-right flex items-center space-x-3">
<button type="button" onclick="toggleKeyboardHelp()" class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-800 px-2 py-1 rounded transition-colors">
📋 F1
</button>
<div class="text-xs">
<div class="text-blue-700">Progression :</div>
<div id="progress-indicator" class="font-semibold text-blue-900">0 / {{ (students|length * grading_elements|length) }} champs</div>
</div>
</div>
</div>
</div>
<!-- Filtre des élèves -->
<div class="bg-white shadow rounded-lg overflow-hidden mb-4">
<div class="px-4 py-3 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-700">Filtrer les élèves</h3>
<span id="student-count" class="text-xs text-gray-500">{{ students|length }} élève(s)</span>
</div>
</div>
<div class="p-4">
<div class="relative">
<input type="text"
id="student-filter"
placeholder="Rechercher un élève par nom ou prénom..."
class="w-full px-3 py-2 pl-10 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
</div>
<button type="button"
id="clear-filter"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 hidden"
onclick="clearStudentFilter()">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
<!-- Tableau de saisie -->
<div class="bg-white shadow rounded-lg overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200">
<h2 class="text-base font-medium text-gray-900">Grille de notation</h2>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 table-fixed">
<!-- En-têtes des exercices -->
<thead class="bg-gradient-to-r from-indigo-50 to-purple-50">
<tr class="border-b-2 border-indigo-200">
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sticky left-0 bg-gradient-to-r from-indigo-50 to-purple-50 border-r border-indigo-200 w-48">
Élève
</th>
{% set current_exercise = '' %}
{% set exercise_elements = {} %}
{% for element in grading_elements %}
{% if element.exercise.title not in exercise_elements %}
{% set _ = exercise_elements.update({element.exercise.title: []}) %}
{% endif %}
{% set _ = exercise_elements[element.exercise.title].append(element) %}
{% endfor %}
{% for exercise_title, elements in exercise_elements.items() %}
<th scope="colgroup" colspan="{{ elements|length }}" class="px-2 py-1.5 text-center text-sm font-bold text-indigo-900 bg-gradient-to-r from-indigo-100 to-purple-100 border-x border-indigo-300">
{{ exercise_title }}
<div class="text-xs font-normal text-indigo-700 mt-0.5">{{ elements|length }} élément{{ 's' if elements|length > 1 else '' }}</div>
</th>
{% endfor %}
</tr>
</thead>
<!-- En-têtes des éléments de notation -->
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-3 py-1.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sticky left-0 bg-gray-50 border-r border-gray-200 w-48">
<!-- Vide pour alignement -->
</th>
{% set current_exercise = '' %}
{% for element in grading_elements %}
{% if element.exercise.title != current_exercise %}
{% set current_exercise = element.exercise.title %}
{% set exercise_color = 'border-l-4 border-' + ['blue', 'green', 'purple', 'orange', 'pink'][loop.index0 % 5] + '-400' %}
{% endif %}
<th scope="col" class="grading-header px-2 py-1.5 text-center text-xs font-medium text-gray-500 uppercase tracking-wider min-w-28 {{ exercise_color }} bg-gradient-to-b from-gray-50 to-gray-100">
<div class="element-label text-xs font-semibold text-gray-900 truncate">{{ element.label }}</div>
{% if element.description %}
<div class="text-xs text-gray-600 mt-1 leading-tight">{{ element.description }}</div>
{% endif %}
<div class="element-type font-normal text-xs mt-1 flex justify-center">
{% if element.grading_type == 'score' %}
<span class="badge-score inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
0-3
</span>
{% else %}
<span class="badge-notes inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
/{{ element.max_points }}
</span>
{% endif %}
</div>
{% if element.skill %}
<div class="text-xs text-gray-400 mt-0.5 truncate" title="{{ element.skill }}">{{ element.skill }}</div>
{% endif %}
</th>
{% endfor %}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-50">
{% for student in students %}
<tr class="student-row hover:bg-gray-25 text-sm transition-colors duration-150" data-student-name="{{ (student.first_name + ' ' + student.last_name)|lower }}">
<td class="px-3 py-1.5 whitespace-nowrap text-sm font-medium text-gray-900 sticky left-0 bg-white border-r border-gray-200">
<div class="flex items-center">
<div class="text-xs text-gray-500 w-6 student-index">{{ loop.index }}</div>
<div>{{ student.first_name }} {{ student.last_name }}</div>
</div>
</td>
{% set current_exercise = '' %}
{% for element in grading_elements %}
{% if element.exercise.title != current_exercise %}
{% set current_exercise = element.exercise.title %}
{% set exercise_bg_color = ['bg-blue-25', 'bg-green-25', 'bg-purple-25', 'bg-orange-25', 'bg-pink-25'][loop.index0 % 5] %}
{% set exercise_border_color = ['border-l-2 border-blue-300', 'border-l-2 border-green-300', 'border-l-2 border-purple-300', 'border-l-2 border-orange-300', 'border-l-2 border-pink-300'][loop.index0 % 5] %}
{% endif %}
{% set grade_key = student.id ~ '_' ~ element.id %}
{% set existing_grade = existing_grades.get(grade_key) %}
<td class="px-1 py-1.5 whitespace-nowrap text-center {{ exercise_border_color if loop.first or (grading_elements[loop.index0-1].exercise.title != element.exercise.title) else '' }} {{ exercise_bg_color }}">
<div class="space-y-1">
<!-- Champs de saisie unifiés -->
{% if element.grading_type == 'score' %}
<select name="grade_{{ student.id }}_{{ element.id }}"
class="grading-input block w-full text-xs border-gray-300 rounded focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all duration-200 py-0.5 h-7"
data-type="score"
data-student-id="{{ student.id }}"
data-element-id="{{ element.id }}"
data-row="{{ loop.index0 }}"
data-col="{{ loop.index0 }}"
onchange="handleGradeChange(this)"
onfocus="handleGradeFocus(this)"
onkeydown="handleGradeKeydown(event, this)">
<option value="" style="color: #6b7280;">-</option>
{% for value in ['0', '1', '2', '3'] %}
{% set display_info = config_manager.get_display_info(value, 'score') %}
<option value="{{ value }}" style="color: {{ display_info.color }}; font-weight: bold;" {% if existing_grade and existing_grade.value == value %}selected{% endif %}>{{ value }} - {{ display_info.label }}</option>
{% endfor %}
{% for special_value in scale_values.keys() if not special_value.isdigit() %}
{% if special_value in scale_values %}
{% set display_info = config_manager.get_display_info(special_value, 'score') %}
<option value="{{ special_value }}" style="color: {{ display_info.color }}; font-style: italic;" {% if existing_grade and existing_grade.value == special_value %}selected{% endif %}>{{ special_value }} - {{ display_info.label }}</option>
{% endif %}
{% endfor %}
</select>
{% else %}
<input type="text"
name="grade_{{ student.id }}_{{ element.id }}"
value="{% if existing_grade %}{{ existing_grade.value }}{% endif %}"
class="grading-input block w-full text-xs border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-center transition-all duration-200 py-0.5 h-7"
data-type="notes"
data-max-points="{{ element.max_points }}"
data-student-id="{{ student.id }}"
data-element-id="{{ element.id }}"
data-row="{{ loop.index0 }}"
data-col="{{ loop.index0 }}"
placeholder="0-{{ element.max_points }}"
oninput="handleGradeChange(this)"
onfocus="handleGradeFocus(this)"
onkeydown="handleGradeKeydown(event, this)">
{% endif %}
<input type="text"
name="comment_{{ student.id }}_{{ element.id }}"
value="{% if existing_grade and existing_grade.comment %}{{ existing_grade.comment }}{% endif %}"
class="comment-input block w-full text-xs border-gray-200 rounded focus:ring-blue-400 focus:border-blue-400 transition-all duration-200 py-0.5 h-6 bg-gray-25"
data-student-id="{{ student.id }}"
data-element-id="{{ element.id }}"
data-row="{{ loop.index0 }}"
data-col="{{ loop.index0 }}"
placeholder="💬"
title="Commentaire (optionnel)"
onkeydown="handleCommentKeydown(event, this)">
</div>
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200 flex justify-between items-center">
<div class="flex items-center space-x-4 text-sm text-gray-600">
<div id="save-status" class="flex items-center">
<span class="w-2 h-2 bg-gray-400 rounded-full mr-2"></span>
<span>Non sauvegardé</span>
</div>
<div id="current-position" class="text-xs">
Position : - / -
</div>
</div>
<div class="flex space-x-3">
<button type="button" onclick="resetForm()" class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
Réinitialiser
</button>
<a href="{{ url_for('assessments.detail', id=assessment.id) }}" class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
Annuler
</a>
<button type="submit" id="save-button" class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors flex items-center">
<span id="save-text">Sauvegarder les notes</span>
<span id="save-spinner" class="ml-2 w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin hidden"></span>
</button>
</div>
</div>
</div>
</form>
<!-- Légende -->
{% endif %}
</div>
<!-- Toast de notification -->
<div id="toast" class="fixed bottom-4 right-4 transform translate-y-full transition-transform duration-300 z-50">
<div class="bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg flex items-center">
<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="M5 13l4 4L19 7"></path>
</svg>
<span id="toast-message">Action réalisée</span>
</div>
</div>
<script>
// Configuration avec couleurs de la base de données
const GRADING_CONFIG = {
types: {
notes: {
label: 'Notes numériques',
description: 'Valeurs décimales (ex: 15.5/20)',
input_type: 'number'
},
score: {
label: 'Échelle de compétences (0-3)',
description: 'Échelle fixe de 0 à 3',
max_value: 3,
input_type: 'select'
}
},
// Couleurs configurées en base
scale_values: {{ scale_values | tojson }},
// Valeurs spéciales pour compatibilité
special_values: {
{% for value in scale_values.keys() if not value.isdigit() %}
'{{ value }}' : {{ scale_values[value] | tojson }}{% if not loop.last %},{% endif %}
{% endfor %}
}
};
// Variables globales
let currentRow = 0;
let currentCol = 0;
let totalRows = {{ students|length }};
let totalCols = {{ grading_elements|length }};
let unsavedChanges = new Set();
let undoStack = [];
let isAutoSaving = false;
let filterIsActive = false; // Nouvelle variable pour tracker l'état du filtre
// Liste dynamique des valeurs spéciales (exclut les scores 0-3)
const SPECIAL_VALUES_KEYS = Object.keys(GRADING_CONFIG.scale_values).filter(key => !['0', '1', '2', '3'].includes(key));
// Initialisation au chargement de la page
document.addEventListener('DOMContentLoaded', function() {
setupKeyboardNavigation();
updateProgressIndicator();
setupAutosave();
applyInitialColors();
setupStudentFilter();
// NE PAS auto-focus - laisser l'utilisateur choisir où cliquer
// setTimeout(() => {
// focusFirstInput();
// }, 100);
});
// Applique les couleurs aux champs déjà remplis au chargement
function applyInitialColors() {
document.querySelectorAll('.grading-input').forEach(input => {
const value = input.value;
const type = input.dataset.type;
const maxPoints = parseFloat(input.dataset.maxPoints) || 20;
if (value && value.trim() !== '') {
const isValid = validateGradeValue(value, type, maxPoints);
applyColorToInput(input, value, type, isValid, maxPoints);
}
});
}
// Configuration de la navigation clavier
function setupKeyboardNavigation() {
// Raccourcis globaux
document.addEventListener('keydown', function(e) {
// PROTECTION ABSOLUE : Ne JAMAIS intercepter si on est dans le champ de filtre
if (e.target.id === 'student-filter' || filterIsActive) {
console.log('Navigation bloquée - filtre actif');
return;
}
// F1 : Aide
if (e.key === 'F1') {
e.preventDefault();
toggleKeyboardHelp();
}
// Ctrl+S : Sauvegarder
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
saveForm();
}
// Ctrl+Z : Annuler dernière modification
if (e.ctrlKey && e.key === 'z') {
e.preventDefault();
undoLastChange();
}
});
}
// Gestion du focus sur un champ de note
function handleGradeFocus(input) {
const row = parseInt(input.dataset.row);
const col = parseInt(input.dataset.col);
currentRow = row;
currentCol = col;
updateCurrentPosition();
// Sélectionner tout le contenu pour faciliter la modification
if (input.type !== 'select-one') {
input.select();
}
}
// Gestion des touches pour les champs de notes
function handleGradeKeydown(event, input) {
// PROTECTION : Ne pas traiter si le filtre est actif
if (filterIsActive) {
console.log('Événement bloqué - filtre actif');
return;
}
const e = event;
// Échap : Vider le champ
if (e.key === 'Escape') {
e.preventDefault();
input.value = '';
handleGradeChange(input);
return;
}
// Navigation clavier pour valeurs spéciales - SIMPLIFIÉ
// On n'intercepte que 'd' et 'a' automatiquement, pas le point
if (SPECIAL_VALUES_KEYS.includes(e.key) && e.key !== '.') {
e.preventDefault();
if (input.tagName === 'SELECT') {
input.value = e.key;
} else {
input.value = e.key;
}
handleGradeChange(input);
navigateToNext('down');
return;
}
// Entrée : Passer au champ suivant dans la même colonne
if (e.key === 'Enter') {
e.preventDefault();
navigateToNext('down');
return;
}
// Navigation avec les flèches
if (e.key === 'ArrowUp') {
e.preventDefault();
navigateToNext('up');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
navigateToNext('down');
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
navigateToNext('left');
} else if (e.key === 'ArrowRight') {
e.preventDefault();
navigateToNext('right');
}
}
// Gestion des touches pour les commentaires
function handleCommentKeydown(event, input) {
if (event.key === 'Enter') {
event.preventDefault();
navigateToNext('down');
}
}
// Navigation vers le champ suivant
function navigateToNext(direction) {
// Ne pas naviguer si le filtre est actif
if (filterIsActive) {
return;
}
let newRow = currentRow;
let newCol = currentCol;
switch(direction) {
case 'up':
newRow = Math.max(0, currentRow - 1);
break;
case 'down':
newRow = Math.min(totalRows - 1, currentRow + 1);
break;
case 'left':
newCol = Math.max(0, currentCol - 1);
break;
case 'right':
newCol = Math.min(totalCols - 1, currentCol + 1);
break;
}
// Trouver et focuser le nouveau champ
const targetInput = document.querySelector(
`.grading-input[data-row="${newRow}"][data-col="${newCol}"]`
);
if (targetInput) {
targetInput.focus();
}
}
// Validation d'une valeur pour un type donné
function validateGradeValue(value, type, maxPoints) {
if (!value || value.trim() === '') return true; // Valeur vide acceptée
const trimmedValue = value.trim();
// Valeurs spéciales configurées
if (GRADING_CONFIG.special_values[trimmedValue]) {
return true;
}
// Validation selon le type
if (type === 'score') {
return ['0', '1', '2', '3'].includes(trimmedValue);
} else if (type === 'notes') {
// Normaliser la virgule en point pour les nombres décimaux français
const normalizedValue = trimmedValue.replace(',', '.');
// Vérifier que c'est un nombre valide
const numValue = parseFloat(normalizedValue);
// Validation stricte : doit être un nombre ET dans la plage
return !isNaN(numValue) &&
numValue >= 0 &&
numValue <= maxPoints &&
/^[0-9]+([.,][0-9]+)?$/.test(trimmedValue); // Format numérique strict
}
return false;
}
// Gestion du changement de valeur avec validation immédiate
function handleGradeChange(input) {
const key = `${input.dataset.studentId}_${input.dataset.elementId}`;
const type = input.dataset.type;
let value = input.value;
const maxPoints = parseFloat(input.dataset.maxPoints) || 20;
// Pour les champs notes, convertir automatiquement virgule en point
if (type === 'notes' && value.includes(',')) {
const convertedValue = value.replace(',', '.');
input.value = convertedValue;
value = convertedValue;
}
// Validation immédiate
const isValid = validateGradeValue(value, type, maxPoints);
// Appliquer la colorisation selon la valeur
applyColorToInput(input, value, type, isValid, maxPoints);
unsavedChanges.add(key);
// Sauvegarder l'état précédent pour l'annulation
undoStack.push({
input: input,
oldValue: input.defaultValue || '',
newValue: input.value
});
// Limiter la pile d'annulation
if (undoStack.length > 50) {
undoStack.shift();
}
updateSaveStatus();
updateProgressIndicator();
// Feedback visuel temporaire de modification
input.classList.add('bg-yellow-50', 'border-yellow-300');
setTimeout(() => {
input.classList.remove('bg-yellow-50', 'border-yellow-300');
// Réappliquer la couleur après l'animation
applyColorToInput(input, value, type, isValid, maxPoints);
}, 1000);
}
// Nouvelle fonction pour appliquer les couleurs aux champs de saisie
function applyColorToInput(input, value, type, isValid, maxPoints) {
// Réinitialiser les classes et styles
input.classList.remove('border-red-300', 'bg-red-50', 'border-green-300', 'bg-green-50');
input.style.backgroundColor = '';
input.style.borderColor = '';
input.style.color = '';
input.style.fontWeight = '';
input.style.fontStyle = '';
if (!value || value.trim() === '') {
// Valeur vide : style neutre
input.style.color = '#6b7280'; // gray-500
return;
}
// Vérifier si c'est une valeur configurée dans l'échelle
if (GRADING_CONFIG.scale_values[value]) {
const config = GRADING_CONFIG.scale_values[value];
input.style.color = config.color;
input.style.backgroundColor = hexToRgba(config.color, 0.1);
input.style.borderColor = hexToRgba(config.color, 0.3);
input.style.fontWeight = 'bold';
// Pour les valeurs spéciales (non numériques), style italique
if (isNaN(value)) {
input.style.fontStyle = 'italic';
}
return;
}
// Validation et colorisation pour valeurs non configurées
if (!isValid) {
// Valeur invalide : rouge
input.style.color = '#dc2626';
input.style.backgroundColor = '#fef2f2';
input.style.borderColor = '#fca5a5';
// Message d'erreur temporaire
showValidationMessage(input,
type === 'score' ? 'Valeur autorisée : 0, 1, 2, 3 ou valeurs spéciales'
: `Valeur autorisée : 0 à ${maxPoints} ou valeurs spéciales`);
return;
}
// Pour les notes numériques non configurées, utiliser un gradient selon la valeur
if (type === 'notes') {
const percentage = Math.min(100, (parseFloat(value) / maxPoints) * 100);
let noteColor;
if (percentage >= 80) {
noteColor = '#059669'; // Vert foncé
} else if (percentage >= 60) {
noteColor = '#22c55e'; // Vert
} else if (percentage >= 40) {
noteColor = '#f6d32d'; // Jaune
} else {
noteColor = '#ef4444'; // Rouge
}
input.style.color = noteColor;
input.style.backgroundColor = hexToRgba(noteColor, 0.1);
input.style.borderColor = hexToRgba(noteColor, 0.3);
input.style.fontWeight = 'bold';
}
}
// Fonction utilitaire pour convertir hex en rgba
function hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
// Mise à jour de l'indicateur de progression
function updateProgressIndicator() {
const filledInputs = document.querySelectorAll('.grading-input').length;
const totalInputs = totalRows * totalCols;
const filledCount = Array.from(document.querySelectorAll('.grading-input')).filter(input =>
input.value && input.value.trim() !== ''
).length;
const progressEl = document.getElementById('progress-indicator');
if (progressEl) {
progressEl.textContent = `${filledCount} / ${totalInputs} champs`;
// Couleur selon progression
progressEl.className = filledCount === 0 ? 'font-semibold text-red-600' :
filledCount === totalInputs ? 'font-semibold text-green-600' :
'font-semibold text-orange-600';
}
}
// Mise à jour de la position actuelle
function updateCurrentPosition() {
const posEl = document.getElementById('current-position');
if (posEl) {
posEl.textContent = `Position : ${currentRow + 1} / ${totalRows} (ligne), ${currentCol + 1} / ${totalCols} (colonne)`;
}
}
// Mise à jour du statut de sauvegarde
function updateSaveStatus() {
const statusEl = document.getElementById('save-status');
const indicator = statusEl.querySelector('.w-2');
const text = statusEl.querySelector('span:last-child');
if (unsavedChanges.size > 0) {
indicator.className = 'w-2 h-2 bg-orange-400 rounded-full mr-2';
text.textContent = `${unsavedChanges.size} modification(s) non sauvegardée(s)`;
} else {
indicator.className = 'w-2 h-2 bg-green-400 rounded-full mr-2';
text.textContent = 'Toutes les modifications sauvegardées';
}
}
// Focuser le premier champ
function focusFirstInput() {
// Ne pas auto-focus si le filtre est actif ou si l'utilisateur interagit avec le filtre
if (filterIsActive || (document.activeElement && document.activeElement.id === 'student-filter')) {
return;
}
const firstInput = document.querySelector('.grading-input');
if (firstInput) {
firstInput.focus();
}
}
// Annuler la dernière modification
function undoLastChange() {
if (undoStack.length === 0) {
showToast('Aucune modification à annuler', 'info');
return;
}
const lastChange = undoStack.pop();
lastChange.input.value = lastChange.oldValue;
handleGradeChange(lastChange.input);
showToast('Modification annulée', 'success');
}
// Réinitialiser le formulaire
function resetForm() {
if (confirm('Êtes-vous sûr de vouloir réinitialiser toutes les notes ? Cette action est irréversible.')) {
document.querySelectorAll('.grading-input, .comment-input').forEach(input => {
input.value = '';
});
unsavedChanges.clear();
undoStack = [];
updateSaveStatus();
updateProgressIndicator();
showToast('Formulaire réinitialisé', 'info');
}
}
// Sauvegarder le formulaire
function saveForm() {
const form = document.getElementById('grading-form');
const saveButton = document.getElementById('save-button');
const saveText = document.getElementById('save-text');
const saveSpinner = document.getElementById('save-spinner');
// Animation de sauvegarde
saveButton.disabled = true;
saveText.textContent = 'Sauvegarde...';
saveSpinner.classList.remove('hidden');
// Simulation de délai de sauvegarde
setTimeout(() => {
form.submit();
}, 500);
}
// Afficher/Masquer l'aide clavier
function toggleKeyboardHelp() {
const help = document.getElementById('keyboard-help');
help.classList.toggle('hidden');
}
// Configuration de la sauvegarde automatique
function setupAutosave() {
setInterval(() => {
if (unsavedChanges.size > 0 && !isAutoSaving) {
autoSave();
}
}, 30000); // Sauvegarde automatique toutes les 30 secondes
}
// Sauvegarde automatique
function autoSave() {
if (isAutoSaving) return;
isAutoSaving = true;
// Ici on pourrait implémenter une sauvegarde AJAX
// Pour l'instant, on se contente d'un indicateur
showToast('Sauvegarde automatique...', 'info');
setTimeout(() => {
isAutoSaving = false;
unsavedChanges.clear();
updateSaveStatus();
showToast('Sauvegardé automatiquement', 'success');
}, 2000);
}
// Afficher un message de validation temporaire
function showValidationMessage(input, message) {
// Supprimer tout message existant
const existingMessage = input.parentNode.querySelector('.validation-message');
if (existingMessage) {
existingMessage.remove();
}
// Créer le message
const messageEl = document.createElement('div');
messageEl.className = 'validation-message absolute z-10 bg-red-100 border border-red-300 text-red-700 px-2 py-1 rounded text-xs mt-1 shadow';
messageEl.textContent = message;
messageEl.style.minWidth = '200px';
// Positionner le message
input.parentNode.style.position = 'relative';
input.parentNode.appendChild(messageEl);
// Supprimer après 3 secondes
setTimeout(() => {
if (messageEl.parentNode) {
messageEl.remove();
}
}, 3000);
}
// Afficher un toast de notification
function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
const toastMessage = document.getElementById('toast-message');
const toastDiv = toast.querySelector('div');
// Couleurs selon le type
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
info: 'bg-blue-500',
warning: 'bg-orange-500'
};
toastDiv.className = `${colors[type]} text-white px-4 py-2 rounded-lg shadow-lg flex items-center`;
toastMessage.textContent = message;
// Animer l'apparition
toast.classList.remove('translate-y-full');
toast.classList.add('translate-y-0');
// Masquer après 3 secondes
setTimeout(() => {
toast.classList.remove('translate-y-0');
toast.classList.add('translate-y-full');
}, 3000);
}
// Configuration du filtre des élèves
function setupStudentFilter() {
const filterInput = document.getElementById('student-filter');
const clearButton = document.getElementById('clear-filter');
const studentCount = document.getElementById('student-count');
if (!filterInput) return;
console.log('Configuration du filtre des élèves');
// Isoler seulement les événements de navigation, pas les événements fonctionnels
['keydown', 'keyup', 'keypress'].forEach(eventType => {
filterInput.addEventListener(eventType, function(e) {
// Ne bloquer que les événements de navigation, pas les caractères normaux
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
e.stopImmediatePropagation();
}
}, true);
});
// Gestion du focus
filterInput.addEventListener('focus', function(e) {
filterIsActive = true;
this.select();
console.log('🔍 Filtre FOCUS - actif:', filterIsActive);
});
// Gestion du blur
filterInput.addEventListener('blur', function(e) {
setTimeout(() => {
if (document.activeElement !== filterInput) {
filterIsActive = false;
console.log('🔍 Filtre BLUR - inactif:', filterIsActive);
}
}, 50);
});
// Gestion de la saisie - SANS bloquer l'événement
filterInput.addEventListener('input', function(e) {
filterIsActive = true;
const searchTerm = this.value.toLowerCase().trim();
console.log('🔍 Filtre INPUT:', searchTerm, 'Longueur:', searchTerm.length);
// Appeler la fonction de filtrage
filterStudents(searchTerm);
// Gérer le bouton de nettoyage
if (searchTerm) {
clearButton.classList.remove('hidden');
} else {
clearButton.classList.add('hidden');
}
});
// Gestion des touches spéciales
filterInput.addEventListener('keydown', function(e) {
console.log('🔍 Filtre KEYDOWN:', e.key);
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
clearStudentFilter();
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
filterIsActive = false;
const firstVisibleInput = document.querySelector('.student-row:not([style*="display: none"]) .grading-input');
if (firstVisibleInput) {
firstVisibleInput.focus();
}
}
// Pour les flèches et Tab, empêcher la navigation
else if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) {
e.preventDefault();
e.stopPropagation();
}
});
}
// Filtrer les élèves selon le terme de recherche
function filterStudents(searchTerm) {
console.log('🔍 filterStudents appelée avec:', searchTerm);
const studentRows = document.querySelectorAll('.student-row');
const studentCount = document.getElementById('student-count');
let visibleCount = 0;
let visibleIndex = 1;
console.log('🔍 Nombre de lignes d\'élèves trouvées:', studentRows.length);
studentRows.forEach((row, index) => {
const studentName = row.dataset.studentName;
const isVisible = !searchTerm || studentName.includes(searchTerm);
console.log(`🔍 Élève ${index + 1}: "${studentName}" - Visible: ${isVisible}`);
if (isVisible) {
row.style.display = '';
visibleCount++;
// Mettre à jour l'index visible
const indexEl = row.querySelector('.student-index');
if (indexEl) {
indexEl.textContent = visibleIndex;
}
visibleIndex++;
} else {
row.style.display = 'none';
}
});
console.log('🔍 Résultats du filtrage:', visibleCount, 'élèves visibles sur', studentRows.length);
// Mettre à jour le compteur
if (studentCount) {
const totalStudents = studentRows.length;
if (searchTerm) {
studentCount.textContent = `${visibleCount} / ${totalStudents} élève(s)`;
studentCount.className = visibleCount === 0 ? 'text-xs text-red-500' : 'text-xs text-blue-600';
} else {
studentCount.textContent = `${totalStudents} élève(s)`;
studentCount.className = 'text-xs text-gray-500';
}
}
// Recalculer les variables globales pour la navigation
totalRows = visibleCount;
// Réinitialiser la position si l'élève actuel n'est plus visible
const currentRowEl = document.querySelector(`.student-row:nth-child(${currentRow + 1})`);
if (currentRowEl && currentRowEl.style.display === 'none') {
// Trouver le premier élève visible
const firstVisibleRow = document.querySelector('.student-row:not([style*="display: none"])');
if (firstVisibleRow) {
const firstVisibleInput = firstVisibleRow.querySelector('.grading-input');
if (firstVisibleInput && !filterIsActive) {
firstVisibleInput.focus();
}
}
}
updateProgressIndicator();
}
// Nettoyer le filtre
function clearStudentFilter() {
const filterInput = document.getElementById('student-filter');
const clearButton = document.getElementById('clear-filter');
if (filterInput) {
filterInput.value = '';
filterStudents('');
clearButton.classList.add('hidden');
// Garder le focus et l'état actif
filterIsActive = true;
setTimeout(() => {
filterInput.focus();
}, 10);
}
}
// Gestion de la fermeture de la page avec modifications non sauvegardées
window.addEventListener('beforeunload', function(e) {
if (unsavedChanges.size > 0) {
e.preventDefault();
e.returnValue = 'Vous avez des modifications non sauvegardées. Êtes-vous sûr de vouloir quitter ?';
}
});
</script>
{% endblock %}