feat: uniform competence management
This commit is contained in:
@@ -3,6 +3,26 @@
|
||||
{% 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>
|
||||
@@ -36,77 +56,131 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="POST" action="{{ url_for('grading.save_grades', assessment_id=assessment.id) }}" class="space-y-6">
|
||||
<!-- Informations sur les types de notation -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<h3 class="text-sm font-medium text-blue-900 mb-2">Guide de saisie</h3>
|
||||
<div class="text-xs text-blue-800 space-y-1">
|
||||
<p><strong>Points :</strong> Saisissez une valeur numérique (ex: 2.5, 3, 0)</p>
|
||||
<p><strong>Score :</strong> 0=non acquis, 1=en cours, 2=acquis, 3=expert, .=non évalué</p>
|
||||
<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> 0=Non acquis, 1=En cours, 2=Acquis, 3=Expert</span>
|
||||
<span class="mx-3">•</span>
|
||||
<span><strong>Spéciaux :</strong>
|
||||
<kbd class="bg-gray-200 px-1 rounded text-xs">.</kbd>=Pas de réponse,
|
||||
<kbd class="bg-gray-200 px-1 rounded text-xs">d</kbd>=Dispensé,
|
||||
<kbd class="bg-gray-200 px-1 rounded text-xs">a</kbd>=Absent
|
||||
</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>
|
||||
|
||||
<!-- Tableau de saisie -->
|
||||
<div class="bg-white shadow rounded-lg overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Grille de notation</h2>
|
||||
<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 class="min-w-full divide-y divide-gray-200 table-fixed">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider sticky left-0 bg-gray-50">
|
||||
<th scope="col" class="px-3 py-2 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">
|
||||
Élève
|
||||
</th>
|
||||
{% for element in grading_elements %}
|
||||
<th scope="col" class="px-3 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider min-w-32">
|
||||
<div>{{ element.label }}</div>
|
||||
<div class="font-normal text-xs mt-1">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||
{% if element.grading_type == 'score' %}bg-purple-100 text-purple-800{% else %}bg-green-100 text-green-800{% endif %}">
|
||||
{% if element.grading_type == 'score' %}Score/{{ element.max_points|int }}{% else %}/{{ element.max_points }}{% endif %}
|
||||
</span>
|
||||
<th scope="col" class="grading-header px-2 py-2 text-center text-xs font-medium text-gray-500 uppercase tracking-wider min-w-28">
|
||||
<div class="element-label text-xs font-semibold text-gray-900">{{ element.label }}</div>
|
||||
<div class="element-type font-normal text-xs mt-1">
|
||||
{% if element.grading_type == 'score' %}
|
||||
<span class="badge-score inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
0-3
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge-notes inline-flex items-center px-2 py-0.5 rounded-full 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-1">{{ element.skill }}</div>
|
||||
<div class="text-xs text-gray-400 mt-1 truncate">{{ element.skill }}</div>
|
||||
{% endif %}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tbody class="bg-white divide-y divide-gray-100">
|
||||
{% for student in students %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 sticky left-0 bg-white">
|
||||
<tr class="hover:bg-gray-50 text-sm">
|
||||
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900 sticky left-0 bg-white border-r border-gray-200">
|
||||
{{ student.first_name }} {{ student.last_name }}
|
||||
</td>
|
||||
{% for element in grading_elements %}
|
||||
{% set grade_key = student.id ~ '_' ~ element.id %}
|
||||
{% set existing_grade = existing_grades.get(grade_key) %}
|
||||
<td class="px-3 py-4 whitespace-nowrap text-center">
|
||||
<div class="space-y-2">
|
||||
<td class="px-1 py-2 whitespace-nowrap text-center">
|
||||
<div class="space-y-1">
|
||||
<!-- Champs de saisie unifiés -->
|
||||
{% if element.grading_type == 'score' %}
|
||||
<select name="grade_{{ student.id }}_{{ element.id }}" class="block w-full text-sm border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
<select name="grade_{{ student.id }}_{{ element.id }}"
|
||||
class="grading-input block w-full text-xs border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all duration-200 py-1"
|
||||
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="">-</option>
|
||||
<option value="." {% if existing_grade and existing_grade.value == '.' %}selected{% endif %}>. (non évalué)</option>
|
||||
<option value="0" {% if existing_grade and existing_grade.value == '0' %}selected{% endif %}>0 (non acquis)</option>
|
||||
<option value="1" {% if existing_grade and existing_grade.value == '1' %}selected{% endif %}>1 (en cours)</option>
|
||||
<option value="2" {% if existing_grade and existing_grade.value == '2' %}selected{% endif %}>2 (acquis)</option>
|
||||
<option value="3" {% if existing_grade and existing_grade.value == '3' %}selected{% endif %}>3 (expert)</option>
|
||||
{% for special_value in ['.', 'd', 'a'] %}
|
||||
{% 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 }}" {% if existing_grade and existing_grade.value == special_value %}selected{% endif %}>{{ special_value }} ({{ display_info.label }})</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% 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 }}" {% if existing_grade and existing_grade.value == value %}selected{% endif %}>{{ value }} ({{ display_info.label }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% else %}
|
||||
<input type="number" step="0.1" min="0" max="{{ element.max_points }}"
|
||||
<input type="text"
|
||||
name="grade_{{ student.id }}_{{ element.id }}"
|
||||
value="{% if existing_grade %}{{ existing_grade.value }}{% endif %}"
|
||||
class="block w-full text-sm border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 text-center"
|
||||
placeholder="0">
|
||||
class="grading-input block w-full text-xs border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-center transition-all duration-200 py-1"
|
||||
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 }} ou {% for v, c in scale_values.items() if v in ['.', 'd', 'a'] %}{{ v }}{% if not loop.last %} {% endif %}{% endfor %}"
|
||||
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="block w-full text-xs border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Commentaire (optionnel)">
|
||||
class="comment-input block w-full text-xs border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 py-0.5"
|
||||
data-student-id="{{ student.id }}"
|
||||
data-element-id="{{ element.id }}"
|
||||
data-row="{{ loop.index0 }}"
|
||||
data-col="{{ loop.index0 }}"
|
||||
placeholder="Commentaire (optionnel)"
|
||||
onkeydown="handleCommentKeydown(event, this)">
|
||||
</div>
|
||||
</td>
|
||||
{% endfor %}
|
||||
@@ -116,44 +190,501 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200 flex justify-end space-x-3">
|
||||
<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" class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors">
|
||||
Sauvegarder les notes
|
||||
</button>
|
||||
<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 -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Légende des exercices</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="space-y-4">
|
||||
{% for exercise in assessment.exercises|sort(attribute='order') %}
|
||||
<div class="border-l-4 border-blue-500 pl-4">
|
||||
<h4 class="font-medium text-gray-900">{{ exercise.title }}</h4>
|
||||
{% if exercise.description %}
|
||||
<p class="text-sm text-gray-600 mt-1">{{ exercise.description }}</p>
|
||||
{% endif %}
|
||||
<div class="mt-2 space-y-1">
|
||||
{% for element in exercise.grading_elements %}
|
||||
<div class="text-sm text-gray-700">
|
||||
<span class="font-medium">{{ element.label }}</span>
|
||||
{% if element.skill %} - {{ element.skill }}{% endif %}
|
||||
{% if element.description %} : {{ element.description }}{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% 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 simple pour 2 types
|
||||
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 : {% for i in range(4) %}{{ i }}={{ scale_values[i|string]['label'] }}{% if not loop.last %}, {% endif %}{% endfor %}',
|
||||
max_value: 3,
|
||||
input_type: 'select'
|
||||
}
|
||||
},
|
||||
special_values: {
|
||||
{% if '.' in scale_values %}'.' : { label: '{{ scale_values['.']['label'] }}', color: '{{ scale_values['.']['color'] }}' }{% endif %}{% if 'd' in scale_values %}{% if '.' in scale_values %},{% endif %}'d' : { label: '{{ scale_values['d']['label'] }}', color: '{{ scale_values['d']['color'] }}' }{% endif %}{% if 'a' in scale_values %}{% if '.' in scale_values or 'd' in scale_values %},{% endif %}'a' : { label: '{{ scale_values['a']['label'] }}', color: '{{ scale_values['a']['color'] }}' }{% endif %}
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
// Liste dynamique des valeurs spéciales
|
||||
const SPECIAL_VALUES_KEYS = Object.keys(GRADING_CONFIG.special_values);
|
||||
|
||||
// Initialisation au chargement de la page
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupKeyboardNavigation();
|
||||
updateProgressIndicator();
|
||||
focusFirstInput();
|
||||
setupAutosave();
|
||||
});
|
||||
|
||||
// Configuration de la navigation clavier
|
||||
function setupKeyboardNavigation() {
|
||||
// Raccourcis globaux
|
||||
document.addEventListener('keydown', function(e) {
|
||||
// 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) {
|
||||
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 (['d', 'a'].includes(e.key) && SPECIAL_VALUES_KEYS.includes(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) {
|
||||
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);
|
||||
|
||||
// Feedback visuel
|
||||
if (GRADING_CONFIG.special_values[value]) {
|
||||
// Valeur spéciale : couleur spécifique
|
||||
input.style.color = GRADING_CONFIG.special_values[value].color;
|
||||
input.classList.remove('border-red-300', 'bg-red-50', 'border-green-300', 'bg-green-50');
|
||||
} else if (!isValid && value.trim() !== '') {
|
||||
// Valeur invalide : rouge
|
||||
input.style.color = '#dc2626';
|
||||
input.classList.add('border-red-300', 'bg-red-50');
|
||||
input.classList.remove('border-green-300', 'bg-green-50');
|
||||
|
||||
// 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`);
|
||||
} else if (isValid && value.trim() !== '') {
|
||||
// Valeur valide : vert léger
|
||||
input.style.color = '#059669';
|
||||
input.classList.remove('border-red-300', 'bg-red-50');
|
||||
input.classList.add('border-green-300', 'bg-green-50');
|
||||
setTimeout(() => {
|
||||
input.classList.remove('border-green-300', 'bg-green-50');
|
||||
}, 1000);
|
||||
} else {
|
||||
// Valeur vide : neutre
|
||||
input.style.color = '';
|
||||
input.classList.remove('border-red-300', 'bg-red-50', 'border-green-300', 'bg-green-50');
|
||||
}
|
||||
|
||||
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
|
||||
input.classList.add('bg-yellow-50', 'border-yellow-300');
|
||||
setTimeout(() => {
|
||||
input.classList.remove('bg-yellow-50', 'border-yellow-300');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 %}
|
||||
Reference in New Issue
Block a user