fix: js errors

This commit is contained in:
2025-08-12 06:41:39 +02:00
parent c132419213
commit 11bfc5c5cb
3 changed files with 1284 additions and 1 deletions

View File

@@ -21,6 +21,7 @@ class StudentTrimesterSummary:
grades_by_assessment: Dict[int, Dict] # assessment_id -> {'score': float, 'max': float, 'title': str}
appreciation: Optional[CouncilAppreciation]
performance_status: str # 'excellent', 'good', 'average', 'struggling'
competence_domain_breakdown: Optional[Dict] = None # Données des compétences et domaines
@property
def has_appreciation(self) -> bool:
@@ -97,13 +98,17 @@ class StudentEvaluationService:
student_id, student.class_group_id, trimester
)
# Calculer les données de compétences et domaines
competence_domain_breakdown = self.get_student_competence_domain_breakdown(student_id, trimester)
return StudentTrimesterSummary(
student=student,
overall_average=overall_average,
assessment_count=len([a for a in assessments if self._has_grades(student_id, a)]),
grades_by_assessment=grades_by_assessment,
appreciation=appreciation,
performance_status=performance_status
performance_status=performance_status,
competence_domain_breakdown=competence_domain_breakdown
)
def get_students_summaries(self, class_group_id: int, trimester: int) -> List[StudentTrimesterSummary]:
@@ -121,6 +126,229 @@ class StudentEvaluationService:
return summaries
def get_student_competence_domain_breakdown(self, student_id: int, trimester: int) -> Dict[str, List[Dict]]:
"""
Calcule la répartition des points par compétence et domaine pour un élève.
Returns:
Dict avec 'competences' et 'domains', chacun contenant les points accumulés par évaluation
"""
from models import Student, db, GradingCalculator
student = Student.query.get(student_id)
if not student:
return {'competences': [], 'domains': []}
# Récupérer toutes les évaluations du trimestre
assessments = self.assessment_repo.find_by_class_trimester_with_details(
student.class_group_id, trimester
)
# Structures pour accumuler les données
competences_data = {}
domains_data = {}
# Parcourir chaque évaluation
for assessment in assessments:
grades = self.grade_repo.get_student_grades_by_assessment(student_id, assessment.id)
for grade in grades:
element = grade.grading_element
# Calculer le score pour cet élément
if grade.value and GradingCalculator.is_counted_in_total(grade.value, element.grading_type):
score = GradingCalculator.calculate_score(
grade.value, element.grading_type, element.max_points
)
if score is not None:
# Traiter les compétences
if element.skill:
if element.skill not in competences_data:
competences_data[element.skill] = {
'name': element.skill,
'earned_points': 0.0,
'total_points': 0.0,
'assessments': {}
}
# Accumuler les points
competences_data[element.skill]['earned_points'] += score
competences_data[element.skill]['total_points'] += element.max_points
# Grouper par évaluation
if assessment.id not in competences_data[element.skill]['assessments']:
competences_data[element.skill]['assessments'][assessment.id] = {
'id': assessment.id,
'title': assessment.title,
'earned': 0.0,
'max': 0.0,
'date': assessment.date
}
competences_data[element.skill]['assessments'][assessment.id]['earned'] += score
competences_data[element.skill]['assessments'][assessment.id]['max'] += element.max_points
# Traiter les domaines
if element.domain_id:
domain = element.domain
domain_name = domain.name if domain else f"Domaine {element.domain_id}"
if domain_name not in domains_data:
domains_data[domain_name] = {
'id': element.domain_id,
'name': domain_name,
'color': domain.color if domain else '#6B7280',
'earned_points': 0.0,
'total_points': 0.0,
'assessments': {}
}
# Accumuler les points
domains_data[domain_name]['earned_points'] += score
domains_data[domain_name]['total_points'] += element.max_points
# Grouper par évaluation
if assessment.id not in domains_data[domain_name]['assessments']:
domains_data[domain_name]['assessments'][assessment.id] = {
'id': assessment.id,
'title': assessment.title,
'earned': 0.0,
'max': 0.0,
'date': assessment.date
}
domains_data[domain_name]['assessments'][assessment.id]['earned'] += score
domains_data[domain_name]['assessments'][assessment.id]['max'] += element.max_points
# Récupérer la configuration des compétences pour les couleurs
from app_config import config_manager
competences_config = {comp['name']: comp for comp in config_manager.get_competences_list()}
# Finaliser les données des compétences
competences = []
for comp_name, comp_data in competences_data.items():
config = competences_config.get(comp_name, {})
# Calculer le pourcentage
percentage = (comp_data['earned_points'] / comp_data['total_points'] * 100) if comp_data['total_points'] > 0 else 0
# Convertir les assessments en liste avec calculs d'accumulation
assessments_list = []
# Palette de couleurs moderne et contrastée
colors_palette = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16']
# D'abord, trier par date pour avoir l'ordre chronologique
sorted_assessments = sorted(comp_data['assessments'].values(),
key=lambda x: x['date'] if x['date'] else datetime.min)
# Calculer l'accumulation progressive
cumulative_earned = 0
cumulative_max = 0
for i, assessment_data in enumerate(sorted_assessments):
# Ajouter les points de cette évaluation à l'accumulation
cumulative_earned += assessment_data['earned']
cumulative_max += assessment_data['max']
# Calculer les pourcentages
cumulative_percentage = (cumulative_earned / comp_data['total_points'] * 100) if comp_data['total_points'] > 0 else 0
assessment_performance = (assessment_data['earned'] / assessment_data['max'] * 100) if assessment_data['max'] > 0 else 0
# Calculer le pourcentage de contribution de cette évaluation par rapport au total final
contribution_percentage = (assessment_data['earned'] / comp_data['total_points'] * 100) if comp_data['total_points'] > 0 else 0
# Couleur cohérente basée sur l'ID de l'évaluation pour garantir la cohérence entre compétences/domaines
assessment_color = colors_palette[assessment_data['id'] % len(colors_palette)]
assessments_list.append({
'id': assessment_data['id'],
'title': assessment_data['title'],
'earned_this': round(assessment_data['earned'], 2), # Points de cette évaluation
'max_this': round(assessment_data['max'], 2), # Max de cette évaluation
'earned_cumulative': round(cumulative_earned, 2), # Total cumulé jusqu'à cette éval
'max_cumulative': round(cumulative_max, 2), # Max cumulé jusqu'à cette éval
'date': assessment_data['date'],
'percentage_cumulative': round(cumulative_percentage, 1), # % cumulé par rapport au total
'percentage_contribution': round(contribution_percentage, 1), # % de contribution individuelle
'performance': round(assessment_performance, 1), # % de réussite de cette évaluation seule
'color': assessment_color # Couleur cohérente basée sur l'ID
})
competences.append({
'name': comp_name,
'color': config.get('color', '#6B7280'),
'earned_points': round(comp_data['earned_points'], 2),
'total_points': round(comp_data['total_points'], 2),
'percentage': round(percentage, 1),
'assessments': assessments_list
})
# Finaliser les données des domaines
domains = []
for domain_data in domains_data.values():
# Calculer le pourcentage
percentage = (domain_data['earned_points'] / domain_data['total_points'] * 100) if domain_data['total_points'] > 0 else 0
# Convertir les assessments en liste avec calculs d'accumulation
assessments_list = []
# Utiliser la même palette que les compétences pour la cohérence
colors_palette = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16']
# D'abord, trier par date pour avoir l'ordre chronologique
sorted_assessments = sorted(domain_data['assessments'].values(),
key=lambda x: x['date'] if x['date'] else datetime.min)
# Calculer l'accumulation progressive
cumulative_earned = 0
cumulative_max = 0
for i, assessment_data in enumerate(sorted_assessments):
# Ajouter les points de cette évaluation à l'accumulation
cumulative_earned += assessment_data['earned']
cumulative_max += assessment_data['max']
# Calculer les pourcentages
cumulative_percentage = (cumulative_earned / domain_data['total_points'] * 100) if domain_data['total_points'] > 0 else 0
assessment_performance = (assessment_data['earned'] / assessment_data['max'] * 100) if assessment_data['max'] > 0 else 0
# Calculer le pourcentage de contribution de cette évaluation par rapport au total final
contribution_percentage = (assessment_data['earned'] / domain_data['total_points'] * 100) if domain_data['total_points'] > 0 else 0
# Couleur cohérente basée sur l'ID de l'évaluation (même logique que les compétences)
assessment_color = colors_palette[assessment_data['id'] % len(colors_palette)]
assessments_list.append({
'id': assessment_data['id'],
'title': assessment_data['title'],
'earned_this': round(assessment_data['earned'], 2), # Points de cette évaluation
'max_this': round(assessment_data['max'], 2), # Max de cette évaluation
'earned_cumulative': round(cumulative_earned, 2), # Total cumulé jusqu'à cette éval
'max_cumulative': round(cumulative_max, 2), # Max cumulé jusqu'à cette éval
'date': assessment_data['date'],
'percentage_cumulative': round(cumulative_percentage, 1), # % cumulé par rapport au total
'percentage_contribution': round(contribution_percentage, 1), # % de contribution individuelle
'performance': round(assessment_performance, 1), # % de réussite de cette évaluation seule
'color': assessment_color # Couleur cohérente basée sur l'ID
})
domains.append({
'id': domain_data['id'],
'name': domain_data['name'],
'color': domain_data['color'],
'earned_points': round(domain_data['earned_points'], 2),
'total_points': round(domain_data['total_points'], 2),
'percentage': round(percentage, 1),
'assessments': assessments_list
})
# Trier par nom
competences.sort(key=lambda x: x['name'].lower())
domains.sort(key=lambda x: x['name'].lower())
return {
'competences': competences,
'domains': domains
}
def _calculate_assessment_score_for_student(self, assessment: Assessment, student_id: int) -> Optional[float]:
"""Calcule le score d'un élève pour une évaluation."""
grades = self.grade_repo.get_student_grades_by_assessment(student_id, assessment.id)

View File

@@ -1058,9 +1058,15 @@ class FocusManager {
// Vider le conteneur
focusContainer.innerHTML = '';
// Sauvegarder les données JSON avant clonage pour éviter la troncature
const savedJsonData = this.preserveJsonDataBeforeCloning(currentStudent);
// Cloner l'élément élève
const clonedStudent = currentStudent.cloneNode(true);
// Restaurer les données JSON après clonage
this.restoreJsonDataAfterCloning(clonedStudent, savedJsonData);
// Marquer comme élément focus pour la synchronisation
const studentId = clonedStudent.dataset.studentCard;
clonedStudent.setAttribute('data-focus-clone-of', studentId);
@@ -1152,6 +1158,406 @@ class FocusManager {
}
});
}
// 4. Gestion des barres de progression
this.setupProgressBars(clonedStudent);
}
setupProgressBars(clonedStudent) {
// Configure les interactions avec les barres de progression des compétences et domaines
try {
// Trouver toutes les barres de progression
const progressBars = clonedStudent.querySelectorAll('.progress-bar-container[data-assessments]');
if (progressBars.length === 0) {
return;
}
progressBars.forEach((bar) => {
try {
// Ajouter les tooltips dynamiques pour la barre complète
this.setupProgressBarTooltip(bar);
// Configurer les segments individuels
this.setupProgressBarSegments(bar);
// Déclencher l'animation d'apparition après un court délai
setTimeout(() => {
bar.classList.add('progress-animated');
}, 300);
} catch (barError) {
console.error('❌ Erreur configuration barre de progression:', barError.message);
}
});
// Animation séquentielle des barres et segments
this.animateProgressBarsSequentially(clonedStudent);
} catch (error) {
console.error('❌ Erreur lors de la configuration des barres de progression:', error.message);
}
}
setupProgressBarTooltip(progressBar) {
try {
const competenceName = progressBar.dataset.competenceName || progressBar.dataset.domainName;
// Nouvelle approche : Extraire les données depuis les segments visibles
const assessmentsData = this.extractAssessmentDataFromSegments(progressBar);
if (assessmentsData && assessmentsData.length > 0) {
// Construire le contenu du tooltip depuis les données extraites
let tooltipContent = `${competenceName || 'Progression'}:\n`;
assessmentsData.forEach(assessment => {
const percentage = assessment.max > 0 ? Math.round((assessment.earned / assessment.max) * 100) : 0;
tooltipContent += `${assessment.title}: ${assessment.earned}/${assessment.max} (${percentage}%)\n`;
});
// Ajouter l'attribut data-tooltip pour les CSS
progressBar.setAttribute('data-tooltip', tooltipContent);
// Événements hover pour améliorer l'UX
progressBar.addEventListener('mouseenter', () => {
progressBar.style.zIndex = '1001';
});
progressBar.addEventListener('mouseleave', () => {
progressBar.style.zIndex = '';
});
} else {
// Fallback : Essayer l'ancienne méthode comme dernier recours
this.setupProgressBarTooltipFallback(progressBar);
}
} catch (error) {
console.error('❌ Erreur setup tooltip barre de progression:', error.message);
// En cas d'erreur, essayer le fallback
this.setupProgressBarTooltipFallback(progressBar);
}
}
extractAssessmentDataFromSegments(progressBar) {
try {
const segments = progressBar.querySelectorAll('.progress-segment[data-assessment-title]');
if (segments.length === 0) {
return [];
}
const assessmentsData = [];
segments.forEach(segment => {
const assessmentData = {
id: segment.dataset.assessmentId || Math.random(),
title: segment.dataset.assessmentTitle || 'Évaluation',
earned: parseFloat(segment.dataset.earnedThis) || 0,
max: parseFloat(segment.dataset.maxThis) || 0,
performance: parseFloat(segment.dataset.assessmentPerformance) || 0,
contribution: parseFloat(segment.dataset.contributionPercentage) || 0
};
// Validation des données extraites
if (assessmentData.title && assessmentData.title !== 'Évaluation') {
assessmentsData.push(assessmentData);
}
});
return assessmentsData;
} catch (error) {
console.error('❌ Erreur extraction données segments:', error.message);
return [];
}
}
setupProgressBarTooltipFallback(progressBar) {
try {
const rawAssessmentsData = progressBar.dataset.assessments;
if (!rawAssessmentsData || rawAssessmentsData.trim() === '') {
return;
}
// Protection contre les JSON malformés
if (!rawAssessmentsData.trim().startsWith('[') && !rawAssessmentsData.trim().startsWith('{')) {
return;
}
let assessmentsData;
try {
assessmentsData = JSON.parse(rawAssessmentsData);
} catch (parseError) {
return;
}
const competenceName = progressBar.dataset.competenceName || progressBar.dataset.domainName;
if (assessmentsData && Array.isArray(assessmentsData) && assessmentsData.length > 0) {
let tooltipContent = `${competenceName || 'Progression'}:\n`;
assessmentsData.forEach(assessment => {
const earned = assessment.earned_this || assessment.earned || 0;
const max = assessment.max_this || assessment.max || 0;
const title = assessment.title || 'Évaluation';
const percentage = max > 0 ? Math.round((earned / max) * 100) : 0;
tooltipContent += `${title}: ${earned}/${max} (${percentage}%)\n`;
});
progressBar.setAttribute('data-tooltip', tooltipContent);
}
} catch (error) {
// Échec silencieux pour le fallback
}
}
setupProgressBarSegments(progressBar) {
// Configure les interactions avec les nouvelles barres segmentées
const segmentedProgressBar = progressBar.querySelector('.segmented-progress-bar');
const segments = progressBar.querySelectorAll('.progress-segment');
const progressContainer = progressBar.closest('.segmented-progress');
if (!segmentedProgressBar || !progressContainer) return;
// Configuration de l'expansion/contraction
this.setupSegmentedProgressInteractions(progressContainer, segmentedProgressBar, segments);
// Configuration des segments individuels
segments.forEach((segment, index) => {
this.setupSegmentInteractions(segment, index, segments);
});
// Support clavier pour l'accessibilité
this.setupKeyboardNavigation(progressContainer, segments);
}
setupSegmentedProgressInteractions(container, progressBar, segments) {
// Le mode expandé est maintenant par défaut, donc moins d'interactions nécessaires
// Garde juste les interactions de base pour la consistance
// Effet de focus pour l'accessibilité
container.addEventListener('mouseenter', () => {
container.style.transform = 'translateY(-1px)';
});
container.addEventListener('mouseleave', () => {
container.style.transform = '';
});
}
setupSegmentInteractions(segment, index, allSegments) {
// Tooltip enrichi au hover
this.enhanceSegmentTooltip(segment);
// Clic sur un segment pour afficher les détails
segment.addEventListener('click', (e) => {
e.stopPropagation();
this.showSegmentDetails(segment);
});
// Effets visuels améliorés
segment.addEventListener('mouseenter', () => {
this.highlightSegment(segment, allSegments);
});
segment.addEventListener('mouseleave', () => {
this.resetSegmentHighlights(allSegments);
});
// Support focus pour accessibilité
segment.addEventListener('focus', () => {
this.highlightSegment(segment, allSegments);
});
segment.addEventListener('blur', () => {
this.resetSegmentHighlights(allSegments);
});
}
// Méthodes utilitaires pour les barres segmentées
setupKeyboardNavigation(container, segments) {
container.addEventListener('keydown', (e) => {
switch(e.key) {
case 'ArrowRight':
case 'ArrowLeft':
// Navigation directe entre les segments
e.preventDefault();
this.navigateSegments(segments, e.key === 'ArrowRight' ? 1 : -1);
break;
case 'Enter':
case ' ':
// Activer le segment focalisé
e.preventDefault();
const focusedSegment = document.activeElement;
if (focusedSegment && focusedSegment.classList.contains('progress-segment')) {
focusedSegment.click();
}
break;
}
});
}
navigateSegments(segments, direction) {
const focusedSegment = document.activeElement;
const currentIndex = Array.from(segments).indexOf(focusedSegment);
if (currentIndex !== -1) {
const nextIndex = Math.max(0, Math.min(segments.length - 1, currentIndex + direction));
segments[nextIndex].focus();
} else if (segments.length > 0) {
segments[direction > 0 ? 0 : segments.length - 1].focus();
}
}
highlightSegment(targetSegment, allSegments) {
allSegments.forEach((segment, index) => {
if (segment === targetSegment) {
segment.style.filter = 'brightness(1.2) saturate(1.1)';
segment.style.transform = 'scaleY(1.05)';
segment.style.zIndex = '20';
} else {
segment.style.filter = 'brightness(0.8) saturate(0.8)';
segment.style.opacity = '0.7';
}
});
}
resetSegmentHighlights(allSegments) {
allSegments.forEach(segment => {
segment.style.filter = '';
segment.style.transform = '';
segment.style.zIndex = '';
segment.style.opacity = '';
});
}
showSegmentDetails(segment) {
const assessmentTitle = segment.dataset.assessmentTitle;
const earnedThis = segment.dataset.earnedThis;
const maxThis = segment.dataset.maxThis;
const performance = segment.dataset.assessmentPerformance;
const contributionPercentage = segment.dataset.contributionPercentage;
const content = `
<div class="space-y-3">
<div class="text-center">
<h4 class="font-semibold text-lg">${assessmentTitle}</h4>
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="bg-blue-50 p-3 rounded-lg">
<div class="font-medium text-blue-800">Points obtenus</div>
<div class="text-xl font-bold text-blue-900">${earnedThis}/${maxThis}</div>
</div>
<div class="bg-green-50 p-3 rounded-lg">
<div class="font-medium text-green-800">Performance</div>
<div class="text-xl font-bold text-green-900">${performance}%</div>
</div>
</div>
<div class="bg-purple-50 p-3 rounded-lg">
<div class="font-medium text-purple-800 mb-1">Contribution au total</div>
<div class="w-full bg-purple-200 rounded-full h-2">
<div class="bg-purple-600 h-2 rounded-full transition-all duration-500"
style="width: ${contributionPercentage}%"></div>
</div>
<div class="text-sm text-purple-700 mt-1">${contributionPercentage}% du total de la compétence</div>
</div>
</div>
`;
const modal = this.parent.ui.createModal(`Détails - ${assessmentTitle}`, content);
document.body.appendChild(modal);
}
announceStateChange(state) {
// Annonce vocale pour les lecteurs d'écran
const announcement = document.createElement('div');
announcement.setAttribute('aria-live', 'polite');
announcement.setAttribute('aria-atomic', 'true');
announcement.className = 'sr-only';
announcement.textContent = `Barre de progression ${state}`;
document.body.appendChild(announcement);
setTimeout(() => announcement.remove(), 1000);
}
enhanceSegmentTooltip(segment) {
// Améliore le tooltip avec plus d'informations visuelles
const assessmentTitle = segment.dataset.assessmentTitle;
const performance = segment.dataset.assessmentPerformance;
const earnedThis = segment.dataset.earnedThis;
const maxThis = segment.dataset.maxThis;
const contributionPercentage = segment.dataset.contributionPercentage;
// Créer un tooltip enrichi pour les nouveaux segments
const tooltipContent = `${assessmentTitle}: ${earnedThis}/${maxThis} pts (${performance}%) - ${contributionPercentage}% du total. Cliquez pour plus de détails.`;
segment.title = tooltipContent;
// Ajouter une classe pour identifier le niveau de performance
const perfNum = parseInt(performance);
if (perfNum >= 90) {
segment.classList.add('segment-excellent');
} else if (perfNum >= 70) {
segment.classList.add('segment-good');
} else if (perfNum >= 50) {
segment.classList.add('segment-average');
} else {
segment.classList.add('segment-struggling');
}
// Ajouter un indicateur visuel de performance dans le style
segment.style.setProperty('--performance-level', perfNum);
}
animateProgressBarsSequentially(clonedStudent) {
// Anime les barres de progression de façon séquentielle pour un effet visuel agréable
const competenceBars = clonedStudent.querySelectorAll('.competence-progress-bar .progress-bar-fill');
const domainBars = clonedStudent.querySelectorAll('.domain-progress-bar .progress-bar-fill');
// Combiner toutes les barres
const allBars = [...competenceBars, ...domainBars];
// Animer chaque barre avec un délai progressif
allBars.forEach((bar, index) => {
// Sauvegarder la largeur finale
const finalWidth = bar.style.width;
// Commencer à 0
bar.style.width = '0%';
// Animer vers la valeur finale avec un délai
setTimeout(() => {
bar.style.width = finalWidth;
bar.style.transition = 'width 0.8s ease-out';
// Effet de "pulse" léger à la fin de l'animation
setTimeout(() => {
bar.style.transform = 'scaleY(1.1)';
setTimeout(() => {
bar.style.transform = 'scaleY(1)';
bar.style.transition = 'width 0.8s ease-out, transform 0.2s ease-out';
}, 150);
}, 800);
}, index * 100); // Délai de 100ms entre chaque barre
});
}
syncProgressBarsToOriginal(studentId) {
// Synchronise les barres de progression entre l'élément original et le clone focus
// Cette fonction sera appelée quand les données changent
// Pour l'instant, les données sont statiques, mais elle sera utile pour les futures évolutions
const originalStudent = document.querySelector(`[data-student-card="${studentId}"]`);
const focusStudent = document.querySelector(`[data-focus-clone-of="${studentId}"]`);
if (!originalStudent || !focusStudent) return;
// Synchroniser les valeurs des barres si nécessaire
console.log(`🔄 Synchronisation des barres de progression pour l'élève ${studentId}`);
}
async saveFocusAppreciation(studentId, appreciation, immediate = false) {
@@ -1352,6 +1758,77 @@ class FocusManager {
window.scrollTo(0, 0);
}
preserveJsonDataBeforeCloning(originalStudent) {
const jsonDataMap = new Map();
try {
const progressBars = originalStudent.querySelectorAll('.progress-bar-container[data-assessments]');
progressBars.forEach((bar, index) => {
const rawData = bar.dataset.assessments;
if (rawData && rawData.trim() !== '') {
const competenceName = bar.dataset.competenceName || bar.dataset.domainName || `progress-${index}`;
const key = `${competenceName}-${index}`;
const preservedData = {
rawData: rawData,
competenceName: competenceName,
domainName: bar.dataset.domainName,
index: index
};
// Essayer de parser pour valider
try {
preservedData.parsedData = JSON.parse(rawData);
preservedData.isValid = true;
} catch (parseError) {
preservedData.isValid = false;
preservedData.parseError = parseError.message;
}
jsonDataMap.set(key, preservedData);
}
});
} catch (error) {
console.error('❌ Erreur sauvegarde données JSON:', error.message);
}
return jsonDataMap;
}
restoreJsonDataAfterCloning(clonedStudent, savedJsonData) {
if (!savedJsonData || savedJsonData.size === 0) {
return;
}
try {
const progressBars = clonedStudent.querySelectorAll('.progress-bar-container');
progressBars.forEach((bar, index) => {
const competenceName = bar.dataset.competenceName || bar.dataset.domainName || `progress-${index}`;
const key = `${competenceName}-${index}`;
const savedData = savedJsonData.get(key);
if (savedData) {
// Restaurer les données sauvegardées
bar.dataset.assessments = savedData.rawData;
if (savedData.competenceName) {
bar.dataset.competenceName = savedData.competenceName;
}
if (savedData.domainName) {
bar.dataset.domainName = savedData.domainName;
}
}
});
} catch (error) {
console.error('❌ Erreur restauration données JSON:', error.message);
}
}
// Méthode appelée après les filtres - NON UTILISÉE en mode focus
onFiltersChanged() {
// En mode focus, on ignore les filtres

View File

@@ -395,6 +395,128 @@
</div>
{% endif %}
{# Section Compétences et Domaines #}
{% if summary.competence_domain_breakdown and (summary.competence_domain_breakdown.competences or summary.competence_domain_breakdown.domains) %}
<div class="competence-domain-section">
<h4 class="font-medium text-gray-700 mb-3 flex items-center">
<svg class="w-4 h-4 text-indigo-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Progression par compétence et domaine
</h4>
{# Barres de compétences #}
{% if summary.competence_domain_breakdown.competences %}
<div class="mb-4">
<h5 class="text-sm font-medium text-purple-700 mb-3 flex items-center">
<svg class="w-3 h-3 text-purple-500 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"/>
</svg>
Compétences
</h5>
<div class="space-y-2">
{% for competence in summary.competence_domain_breakdown.competences %}
<div class="competence-progress-bar">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-700">{{ competence.name }}</span>
<span class="text-sm font-bold" style="color: {{ competence.color }}">
{{ competence.percentage }}% ({{ competence.earned_points }}/{{ competence.total_points }})
</span>
</div>
<div class="progress-bar-container segmented-progress"
data-competence-name="{{ competence.name }}"
data-assessments="{{ competence.assessments | tojson | e }}"
role="progressbar"
aria-valuenow="{{ competence.percentage }}"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Progression de la compétence {{ competence.name }}: {{ competence.percentage }}%"
tabindex="0">
<div class="segmented-progress-bar" data-expanded="true">
<!-- Segments pour chaque évaluation -->
{% for assessment in competence.assessments %}
<div class="progress-segment"
style="width: {{ assessment.percentage_contribution }}%; background-color: {{ assessment.color }};"
data-assessment-id="{{ assessment.id }}"
data-assessment-title="{{ assessment.title }}"
data-assessment-performance="{{ assessment.performance }}"
data-earned-this="{{ assessment.earned_this }}"
data-max-this="{{ assessment.max_this }}"
data-earned-cumulative="{{ assessment.earned_cumulative }}"
data-max-cumulative="{{ assessment.max_cumulative }}"
data-contribution-percentage="{{ assessment.percentage_contribution }}"
title="{{ assessment.title }}: {{ assessment.earned_this }} pts ({{ assessment.performance }}%)"
role="button"
tabindex="0"
aria-label="Évaluation {{ assessment.title }}: {{ assessment.earned_this }} points sur {{ assessment.max_this }}">
<span class="segment-label">{{ assessment.title }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# Barres de domaines #}
{% if summary.competence_domain_breakdown.domains %}
<div>
<h5 class="text-sm font-medium text-orange-700 mb-3 flex items-center">
<svg class="w-3 h-3 text-orange-500 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 111.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 011-1z" clip-rule="evenodd"/>
</svg>
Domaines
</h5>
<div class="space-y-2">
{% for domain in summary.competence_domain_breakdown.domains %}
<div class="domain-progress-bar">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-700">{{ domain.name }}</span>
<span class="text-sm font-bold" style="color: {{ domain.color }}">
{{ domain.percentage }}% ({{ domain.earned_points }}/{{ domain.total_points }})
</span>
</div>
<div class="progress-bar-container segmented-progress"
data-domain-name="{{ domain.name }}"
data-assessments="{{ domain.assessments | tojson | e }}"
role="progressbar"
aria-valuenow="{{ domain.percentage }}"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Progression du domaine {{ domain.name }}: {{ domain.percentage }}%"
tabindex="0">
<div class="segmented-progress-bar" data-expanded="true">
<!-- Segments pour chaque évaluation -->
{% for assessment in domain.assessments %}
<div class="progress-segment"
style="width: {{ assessment.percentage_contribution }}%; background-color: {{ assessment.color }};"
data-assessment-id="{{ assessment.id }}"
data-assessment-title="{{ assessment.title }}"
data-assessment-performance="{{ assessment.performance }}"
data-earned-this="{{ assessment.earned_this }}"
data-max-this="{{ assessment.max_this }}"
data-earned-cumulative="{{ assessment.earned_cumulative }}"
data-max-cumulative="{{ assessment.max_cumulative }}"
data-contribution-percentage="{{ assessment.percentage_contribution }}"
title="{{ assessment.title }}: {{ assessment.earned_this }} pts ({{ assessment.performance }}%)"
role="button"
tabindex="0"
aria-label="Évaluation {{ assessment.title }}: {{ assessment.earned_this }} points sur {{ assessment.max_this }}">
<span class="segment-label">{{ assessment.title }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
{# Zone d'appréciation #}
<div>
<label class="block font-medium text-gray-700 mb-3 flex items-center">
@@ -647,6 +769,462 @@ body.focus-mode {
margin-bottom: 0.5rem;
}
/* ========== BARRES DE PROGRESSION COMPÉTENCES/DOMAINES ========== */
/* ========== BARRES DE PROGRESSION SEGMENTÉES ========== */
/* Conteneur principal des barres de progression */
.competence-progress-bar,
.domain-progress-bar {
margin-bottom: 0.75rem;
}
.progress-bar-container {
position: relative;
cursor: pointer;
transition: all 0.3s ease;
}
/* Conteneur segmenté avec apparence moderne et claire */
.segmented-progress {
border-radius: 10px;
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(0, 0, 0, 0.08);
padding: 6px;
min-height: 44px;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.segmented-progress:hover {
border-color: rgba(0, 0, 0, 0.12);
background: rgba(255, 255, 255, 0.95);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.1);
}
.segmented-progress:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Barre segmentée - état expandé par défaut */
.segmented-progress-bar {
display: flex;
height: 36px;
gap: 3px;
padding: 3px;
border-radius: 6px;
overflow: visible;
position: relative;
background: transparent;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* État contracté (optionnel, si besoin futur) */
.segmented-progress-bar[data-expanded="false"] {
height: 16px;
gap: 0;
padding: 0;
background: #e5e7eb;
overflow: hidden;
}
/* Segments individuels - état expandé par défaut */
.progress-segment {
height: 30px;
border-radius: 6px;
position: relative;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
min-width: 8px; /* Minimum pour les petites contributions */
border: 2px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
animation: segmentAppear 0.6s ease-out forwards;
overflow: hidden;
}
/* État contracté des segments (optionnel) */
.segmented-progress-bar[data-expanded="false"] .progress-segment {
height: 100%;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: none;
}
/* Séparateurs visuels entre segments (état contracté seulement) */
.segmented-progress-bar:not([data-expanded="true"]) .progress-segment:not(:last-child) {
border-right: 2px solid rgba(255, 255, 255, 0.8);
}
/* Hover effects pour les segments */
.progress-segment:hover {
filter: brightness(1.15);
transform: scaleY(1.05);
z-index: 10;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
}
.segmented-progress-bar[data-expanded="true"] .progress-segment:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
/* Focus pour accessibilité clavier */
.progress-segment:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
z-index: 15;
}
/* Labels des segments - visibles par défaut */
.segment-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.7rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
opacity: 1;
transition: opacity 0.3s ease;
pointer-events: none;
white-space: nowrap;
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
}
/* État contracté des labels (optionnel) */
.segmented-progress-bar[data-expanded="false"] .segment-label {
opacity: 0;
font-size: 0.65rem;
}
/* Tooltips améliorés avec fond clair */
.progress-segment:hover::after {
content: attr(data-assessment-title) ': ' attr(data-earned-this) ' pts (' attr(data-assessment-performance) '%)';
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.98);
color: #374151;
padding: 8px 12px;
border-radius: 8px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
z-index: 1001;
pointer-events: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.08);
animation: tooltipFadeIn 0.2s ease-out;
}
.progress-segment:hover::before {
content: '';
position: absolute;
bottom: calc(100% + 2px);
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(255, 255, 255, 0.98);
z-index: 1001;
pointer-events: none;
}
/* Animation d'apparition des segments */
@keyframes segmentAppear {
0% {
opacity: 0;
transform: scaleX(0);
}
60% {
opacity: 0.9;
transform: scaleX(1.02);
}
100% {
opacity: 1;
transform: scaleX(1);
}
}
@keyframes tooltipFadeIn {
0% {
opacity: 0;
transform: translateX(-50%) translateY(4px);
}
100% {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* Animation séquentielle des segments */
.progress-segment:nth-child(1) { animation-delay: 0.1s; }
.progress-segment:nth-child(2) { animation-delay: 0.2s; }
.progress-segment:nth-child(3) { animation-delay: 0.3s; }
.progress-segment:nth-child(4) { animation-delay: 0.4s; }
.progress-segment:nth-child(5) { animation-delay: 0.5s; }
.progress-segment:nth-child(6) { animation-delay: 0.6s; }
/* ========== RESPONSIVE ET ACCESSIBILITÉ ========== */
/* Responsive pour mobile */
@media (max-width: 640px) {
.segmented-progress {
padding: 3px;
min-height: 24px;
}
.segmented-progress-bar {
height: 20px;
}
.segmented-progress-bar[data-expanded="true"] {
height: 44px; /* Touch target minimum 44px */
gap: 2px;
padding: 2px;
}
.segmented-progress-bar[data-expanded="true"] .progress-segment {
height: 40px;
min-width: 44px; /* Touch target minimum */
}
.segment-label {
font-size: 0.6rem;
}
.segmented-progress-bar[data-expanded="true"] .segment-label {
font-size: 0.65rem;
}
.progress-segment:hover::after {
font-size: 0.7rem;
padding: 6px 10px;
bottom: calc(100% + 12px);
}
}
/* Responsive pour très petits écrans */
@media (max-width: 480px) {
.competence-progress-bar .flex.items-center.justify-between,
.domain-progress-bar .flex.items-center.justify-between {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.progress-segment:hover::after {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
bottom: auto;
max-width: 280px;
text-align: center;
font-size: 0.75rem;
padding: 8px 12px;
}
}
/* Mode sombre - Réduction de mouvement pour accessibilité */
@media (prefers-reduced-motion: reduce) {
.progress-segment,
.segmented-progress,
.segmented-progress-bar,
.segment-label,
.progress-summary {
animation: none !important;
transition: none !important;
}
.progress-segment:hover {
transform: none;
}
}
/* Contraste élevé pour accessibilité */
@media (prefers-contrast: high) {
.segmented-progress {
border: 3px solid #000000;
}
.progress-segment {
border: 2px solid #ffffff;
filter: contrast(1.2);
}
.segment-label {
text-shadow: 1px 1px 0 #000000;
font-weight: 700;
}
}
/* Mode focus amélioré pour les barres segmentées */
.focus-mode-student .segmented-progress {
padding: 2px;
min-height: 16px;
}
.focus-mode-student .segmented-progress-bar {
height: 12px;
}
.focus-mode-student .segmented-progress-bar[data-expanded="true"] {
height: 28px;
}
.focus-mode-student .segmented-progress-bar[data-expanded="true"] .progress-segment {
height: 24px;
}
/* ========== CLASSES UTILITAIRES D'ACCESSIBILITÉ ========== */
/* Screen reader only content */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Skip link pour navigation au clavier */
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
border-radius: 4px;
z-index: 9999;
transition: top 0.3s;
}
.skip-link:focus {
top: 6px;
}
/* Focus visible amélioré pour tous les éléments interactifs */
.progress-segment:focus-visible,
.segmented-progress:focus-visible {
outline: 3px solid #4f46e5;
outline-offset: 2px;
border-radius: 4px;
}
/* Indicateur de charge pour les animations */
.progress-segment[aria-busy="true"]::after {
content: '';
position: absolute;
top: 2px;
right: 2px;
width: 8px;
height: 8px;
border: 1px solid rgba(255,255,255,0.8);
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Mode sombre - support système */
@media (prefers-color-scheme: dark) {
.segmented-progress {
background: rgba(31, 41, 55, 0.8);
border-color: rgba(75, 85, 99, 0.4);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.segmented-progress:hover {
background: rgba(31, 41, 55, 0.95);
border-color: rgba(75, 85, 99, 0.6);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.3);
}
.progress-segment {
border-color: rgba(255, 255, 255, 0.2);
}
.segment-label {
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.progress-segment:hover::after {
background: rgba(17, 24, 39, 0.95);
color: #f9fafb;
border-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.progress-segment:hover::before {
border-top-color: rgba(17, 24, 39, 0.95);
}
}
/* ========== CLASSES DE PERFORMANCE POUR LES NOUVEAUX SEGMENTS ========== */
/* Indicateurs visuels de performance pour les nouveaux segments */
.progress-segment.segment-excellent {
box-shadow: inset 0 0 0 2px rgba(34, 197, 94, 0.4);
}
.progress-segment.segment-good {
box-shadow: inset 0 0 0 2px rgba(59, 130, 246, 0.4);
}
.progress-segment.segment-average {
box-shadow: inset 0 0 0 2px rgba(245, 158, 11, 0.4);
}
.progress-segment.segment-struggling {
box-shadow: inset 0 0 0 2px rgba(239, 68, 68, 0.4);
}
/* Effet de pulsation pour les segments excellents */
.progress-segment.segment-excellent:hover {
animation: excellentPulse 0.6s ease-in-out;
}
@keyframes excellentPulse {
0%, 100% { filter: brightness(1.2); }
50% { filter: brightness(1.4); }
}
/* ========== ANIMATION D'APPARITION DE LA SECTION ========== */
/* Animation d'apparition pour toute la section */
.competence-domain-section {
animation: slideInUp 0.5s ease-out;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Boutons de navigation désactivés */
button:disabled {
opacity: 0.5;