Files
notytex/static/js/CouncilPreparation.js

1909 lines
75 KiB
JavaScript

/**
* NOTYTEX - Council Preparation Module
* Gestion complète de la préparation du conseil de classe avec auto-sauvegarde
* et navigation trimestre fluide
*/
class CouncilPreparation {
constructor(classId, options = {}) {
this.classId = classId;
this.options = {
debounceTime: 2000, // Auto-sauvegarde après 2s d'inactivité
searchDebounceTime: 300, // Recherche instantanée
cacheTimeout: 10 * 60 * 1000, // Cache 10 minutes
animationDuration: 300, // Durée animations en ms
enableTouchGestures: true,
...options
};
// État centralisé
this.state = {
currentTrimester: parseInt(document.querySelector('[data-council-preparation]')?.dataset?.trimester) || 2,
expandedStudents: new Set(),
searchTerm: '',
sortBy: 'alphabetical', // alphabetical, average, status
filterStatus: 'all', // all, completed, pending, struggling
cache: new Map(),
savingStates: new Map(), // Track saving per student
modifiedAppreciations: new Set(),
isInitialized: false,
// État mode focus
isFocusMode: false,
focusCurrentIndex: 0,
filteredStudents: [] // Liste des élèves visibles (après filtres)
};
// Gestionnaires de sauvegarde par élève
this.saveHandlers = new Map();
this.init();
}
async init() {
try {
this.cacheElements();
// Initialisation des gestionnaires
this.stateManager = new StateManager(this);
this.filterManager = new FilterManager(this);
this.autoSaveManager = new AutoSaveManager(this);
this.uiManager = new UIManager(this);
this.focusManager = new FocusManager(this);
// Restauration de l'état
this.stateManager.restoreState();
// Initialisation des sous-modules
this.filterManager.init();
this.autoSaveManager.init();
this.uiManager.init();
this.focusManager.init();
this.state.isInitialized = true;
// Setup advanced features
this.setupAdvancedFeatures();
} catch (error) {
console.error('Erreur initialisation CouncilPreparation:', error);
this.showError('Erreur lors du chargement de la page');
}
}
cacheElements() {
this.elements = {
container: document.querySelector('[data-council-preparation]'),
studentsContainer: document.querySelector('[data-students-container]'),
searchInput: document.querySelector('[data-search-students]'),
sortSelect: document.querySelector('[data-sort-students]'),
filterSelect: document.querySelector('[data-filter-status]'),
loadingOverlay: document.querySelector('[data-loading-overlay]'),
resultsCounter: document.querySelector('[data-results-counter]'),
trimesterSelector: document.querySelector('[data-trimester-selector]'),
focusModeToggle: document.querySelector('[data-toggle-focus-mode]'),
focusModeText: document.querySelector('[data-focus-mode-text]'),
focusContainer: document.querySelector('[data-focus-container]'),
listModeControls: document.querySelector('.list-mode-controls'),
focusModeControls: document.querySelector('.focus-mode-controls'),
listModeDisplay: document.querySelector('.list-mode-display'),
focusModeDisplay: document.querySelector('.focus-mode-display'),
listModeFilters: document.querySelector('.list-mode-filters'),
listModeActions: document.querySelector('.list-mode-actions'),
listModeHero: document.querySelector('.list-mode-hero'),
listModeBreadcrumb: document.querySelector('.list-mode-breadcrumb'),
listModeFiltersSection: document.querySelector('.list-mode-filters-section'),
listModeStats: document.querySelector('.list-mode-stats'),
focusModeHeader: document.querySelector('.focus-mode-header'),
focusPrevBtn: document.querySelector('[data-focus-prev]'),
focusNextBtn: document.querySelector('[data-focus-next]'),
focusCurrentSpan: document.querySelector('[data-focus-current]'),
focusTotalSpan: document.querySelector('[data-focus-total]')
};
}
setupAdvancedFeatures() {
// Global keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
switch (e.key) {
case 's':
e.preventDefault();
this.autoSaveManager.saveAllPending();
this.showToast('Toutes les appréciations ont été sauvegardées', 'success');
break;
case 'f':
e.preventDefault();
this.elements.searchInput?.focus();
break;
}
}
});
// Save before page unload
window.addEventListener('beforeunload', (e) => {
if (this.state.modifiedAppreciations.size > 0) {
this.autoSaveManager.saveAllPending();
e.returnValue = 'Des modifications non sauvegardées pourraient être perdues.';
}
});
// Setup export handlers
this.setupExportHandlers();
}
setupExportHandlers() {
const exportPdf = document.querySelector('[data-export-pdf]');
const classSynthesis = document.querySelector('[data-class-synthesis]');
exportPdf?.addEventListener('click', () => {
this.showToast('Fonctionnalité d\'export PDF en cours de développement', 'info');
});
classSynthesis?.addEventListener('click', () => {
this.showClassSynthesis();
});
}
showClassSynthesis() {
// Calculer et afficher une synthèse de classe
const students = Array.from(document.querySelectorAll('[data-student-card]'));
const stats = this.calculateClassStats(students);
const modal = this.createModal('Synthèse de classe', this.generateSynthesisHTML(stats));
document.body.appendChild(modal);
}
calculateClassStats(students) {
let totalStudents = students.length;
let withAppreciation = 0;
let averageSum = 0;
let studentsWithGrades = 0;
students.forEach(student => {
if (student.dataset.hasAppreciation === 'true') {
withAppreciation++;
}
const average = parseFloat(student.dataset.studentAverage);
if (average > 0) {
averageSum += average;
studentsWithGrades++;
}
});
return {
totalStudents,
withAppreciation,
appreciationPercentage: Math.round((withAppreciation / totalStudents) * 100),
classAverage: studentsWithGrades > 0 ? (averageSum / studentsWithGrades).toFixed(2) : 'N/A'
};
}
generateSynthesisHTML(stats) {
return `
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="bg-blue-50 p-4 rounded-lg">
<div class="text-2xl font-bold text-blue-900">${stats.totalStudents}</div>
<div class="text-blue-700">Élèves total</div>
</div>
<div class="bg-green-50 p-4 rounded-lg">
<div class="text-2xl font-bold text-green-900">${stats.withAppreciation}</div>
<div class="text-green-700">Appréciations rédigées</div>
</div>
</div>
<div class="bg-gray-50 p-4 rounded-lg">
<div class="flex justify-between items-center">
<span>Progression des appréciations</span>
<span class="font-bold">${stats.appreciationPercentage}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2 mt-2">
<div class="bg-gradient-to-r from-green-500 to-green-600 h-2 rounded-full transition-all duration-500"
style="width: ${stats.appreciationPercentage}%"></div>
</div>
</div>
<div class="text-center pt-4 border-t">
<div class="text-lg">Moyenne de classe : <span class="font-bold text-orange-600">${stats.classAverage}/20</span></div>
</div>
</div>
`;
}
createModal(title, content) {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
modal.innerHTML = `
<div class="bg-white rounded-xl p-6 max-w-md w-full mx-4">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">${title}</h3>
<button class="text-gray-400 hover:text-gray-600" onclick="this.closest('.fixed').remove()">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
${content}
<div class="mt-6 flex justify-end">
<button class="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
onclick="this.closest('.fixed').remove()">
Fermer
</button>
</div>
</div>
`;
// Close on outside click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
return modal;
}
showError(message) {
this.showToast(message, 'error');
}
showToast(message, type = 'info', duration = 3000) {
const toast = document.createElement('div');
const bgColor = {
'success': 'bg-green-500',
'error': 'bg-red-500',
'info': 'bg-blue-500',
'warning': 'bg-yellow-500'
}[type] || 'bg-gray-500';
toast.className = `fixed top-4 right-4 ${bgColor} text-white px-4 py-2 rounded-lg shadow-lg z-50 transform translate-x-full transition-transform duration-300`;
toast.textContent = message;
document.body.appendChild(toast);
// Animate in
setTimeout(() => {
toast.style.transform = 'translateX(0)';
}, 10);
// Animate out and remove
setTimeout(() => {
toast.style.transform = 'translateX(full)';
setTimeout(() => toast.remove(), 300);
}, duration);
}
}
class StateManager {
constructor(councilPrep) {
this.parent = councilPrep;
}
restoreState() {
// Restore from URL and localStorage
const params = new URLSearchParams(location.search);
this.parent.state.sortBy = params.get('sort') || 'alphabetical';
this.parent.state.filterStatus = params.get('filter') || 'all';
// Apply initial filters
this.applyInitialState();
}
applyInitialState() {
if (this.parent.elements.sortSelect) {
this.parent.elements.sortSelect.value = this.parent.state.sortBy;
}
if (this.parent.elements.filterSelect) {
this.parent.elements.filterSelect.value = this.parent.state.filterStatus;
}
}
saveState() {
const params = new URLSearchParams(location.search);
params.set('sort', this.parent.state.sortBy);
params.set('filter', this.parent.state.filterStatus);
history.replaceState(null, '', `${location.pathname}?${params.toString()}`);
}
}
class FilterManager {
constructor(councilPrep) {
this.parent = councilPrep;
this.searchHandler = null;
}
init() {
this.bindSearchEvents();
this.bindSortEvents();
this.bindFilterEvents();
this.bindTrimesterEvents();
}
bindSearchEvents() {
const searchInput = this.parent.elements.searchInput;
if (!searchInput) return;
this.searchHandler = this.debounce((term) => {
this.parent.state.searchTerm = term.toLowerCase();
this.applyFilters();
}, this.parent.options.searchDebounceTime);
searchInput.addEventListener('input', (e) => {
this.searchHandler(e.target.value);
});
}
bindSortEvents() {
const sortSelect = this.parent.elements.sortSelect;
if (!sortSelect) return;
sortSelect.addEventListener('change', (e) => {
this.parent.state.sortBy = e.target.value;
this.parent.stateManager.saveState();
this.applyFilters();
});
}
bindFilterEvents() {
const filterSelect = this.parent.elements.filterSelect;
if (!filterSelect) return;
filterSelect.addEventListener('change', (e) => {
this.parent.state.filterStatus = e.target.value;
this.parent.stateManager.saveState();
this.applyFilters();
});
}
bindTrimesterEvents() {
const trimesterSelector = this.parent.elements.trimesterSelector;
if (!trimesterSelector) return;
// Support pour les nouveaux boutons tabs
if (trimesterSelector.hasAttribute('data-trimester-selector')) {
// Navigation tabs - écouter les clics sur les boutons
const trimesterTabs = trimesterSelector.querySelectorAll('[data-trimester-tab]');
trimesterTabs.forEach(tab => {
tab.addEventListener('click', (e) => {
e.preventDefault();
const newTrimester = parseInt(tab.dataset.trimesterTab);
if (newTrimester !== this.parent.state.currentTrimester) {
this.changeTrimester(newTrimester);
}
});
// Support clavier pour accessibilité
tab.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const newTrimester = parseInt(tab.dataset.trimesterTab);
if (newTrimester !== this.parent.state.currentTrimester) {
this.changeTrimester(newTrimester);
}
}
});
});
} else {
// Ancien sélecteur dropdown - pour compatibilité
trimesterSelector.addEventListener('change', (e) => {
const newTrimester = parseInt(e.target.value);
if (newTrimester !== this.parent.state.currentTrimester) {
this.changeTrimester(newTrimester);
}
});
}
}
async changeTrimester(newTrimester) {
try {
// Show loading overlay
this.parent.elements.loadingOverlay?.classList.remove('hidden');
// Redirect to the new trimester URL
const newUrl = `${window.location.pathname}?trimestre=${newTrimester}`;
window.location.href = newUrl;
} catch (error) {
console.error('Erreur changement trimestre:', error);
this.parent.showToast('Erreur lors du changement de trimestre', 'error');
// Revert selector to previous value
const trimesterSelector = this.parent.elements.trimesterSelector;
if (trimesterSelector.hasAttribute('data-trimester-selector')) {
// Pour les tabs - remettre les bonnes classes active
const tabs = trimesterSelector.querySelectorAll('[data-trimester-tab]');
tabs.forEach(tab => {
const tabTrimester = parseInt(tab.dataset.trimesterTab);
if (tabTrimester === this.parent.state.currentTrimester) {
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
} else {
tab.classList.remove('active');
tab.setAttribute('aria-selected', 'false');
}
});
} else {
// Pour l'ancien dropdown
trimesterSelector.value = this.parent.state.currentTrimester;
}
} finally {
// Hide loading overlay
this.parent.elements.loadingOverlay?.classList.add('hidden');
}
}
applyFilters() {
const students = Array.from(document.querySelectorAll('[data-student-card]'));
let visibleCount = 0;
students.forEach((studentCard, index) => {
const isVisible = this.shouldShowStudent(studentCard);
if (isVisible) {
studentCard.style.display = '';
visibleCount++;
// Staggered animation
studentCard.style.opacity = '0';
studentCard.style.transform = 'translateY(10px)';
setTimeout(() => {
studentCard.style.transition = 'opacity 300ms ease, transform 300ms ease';
studentCard.style.opacity = '1';
studentCard.style.transform = 'translateY(0)';
}, index * 50);
} else {
studentCard.style.display = 'none';
}
});
this.updateResultsCounter(visibleCount, students.length);
this.applySorting();
// Notifier le focus manager si nécessaire
if (this.parent.focusManager) {
this.parent.focusManager.onFiltersChanged();
}
}
shouldShowStudent(studentCard) {
const studentName = studentCard.dataset.studentName?.toLowerCase() || '';
const performanceStatus = studentCard.dataset.performanceStatus;
const hasAppreciation = studentCard.dataset.hasAppreciation === 'true';
// Search filter
if (this.parent.state.searchTerm && !studentName.includes(this.parent.state.searchTerm)) {
return false;
}
// Status filter
if (this.parent.state.filterStatus !== 'all') {
switch (this.parent.state.filterStatus) {
case 'completed':
if (!hasAppreciation) return false;
break;
case 'pending':
if (hasAppreciation) return false;
break;
case 'struggling':
if (performanceStatus !== 'struggling') return false;
break;
}
}
return true;
}
applySorting() {
const container = this.parent.elements.studentsContainer;
if (!container) return;
const students = Array.from(container.querySelectorAll('[data-student-card]:not([style*="display: none"])'));
students.sort((a, b) => {
switch (this.parent.state.sortBy) {
case 'alphabetical':
return (a.dataset.studentName || '').localeCompare(b.dataset.studentName || '');
case 'average':
return (parseFloat(b.dataset.studentAverage) || 0) - (parseFloat(a.dataset.studentAverage) || 0);
case 'status':
const statusOrder = { 'struggling': 0, 'average': 1, 'good': 2, 'excellent': 3, 'no_data': 4 };
return statusOrder[a.dataset.performanceStatus] - statusOrder[b.dataset.performanceStatus];
default:
return 0;
}
});
students.forEach((student, index) => {
student.style.order = index;
});
}
updateResultsCounter(visible, total) {
const counter = this.parent.elements.resultsCounter;
if (counter) {
counter.textContent = `${visible} élève${visible > 1 ? 's' : ''} affiché${visible > 1 ? 's' : ''} sur ${total}`;
}
}
debounce(func, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
}
class AutoSaveManager {
constructor(councilPrep) {
this.parent = councilPrep;
this.pendingSaves = new Map();
this.saveQueue = [];
this.isSaving = false;
}
init() {
this.bindTextareaEvents();
this.bindManualSaveButtons();
}
bindTextareaEvents() {
const textareas = document.querySelectorAll('[data-appreciation-textarea]');
textareas.forEach(textarea => {
const studentId = textarea.dataset.studentId;
// Setup character counter
this.setupCharacterCounter(textarea, studentId);
// Debounced save handler
const saveHandler = this.debounce(() => {
this.queueSave(studentId, textarea.value);
}, this.parent.options.debounceTime);
textarea.addEventListener('input', (e) => {
this.showModifiedState(studentId, true);
this.parent.state.modifiedAppreciations.add(studentId);
this.updateCharacterCounter(textarea, studentId);
saveHandler();
});
textarea.addEventListener('blur', () => {
if (this.parent.state.modifiedAppreciations.has(studentId)) {
this.queueSave(studentId, textarea.value, true);
}
});
});
}
bindManualSaveButtons() {
// Boutons supprimés de l'interface - auto-sauvegarde uniquement
}
setupCharacterCounter(textarea, studentId) {
const counter = textarea.closest('[data-student-details]')?.querySelector('[data-char-counter]');
if (counter) {
this.updateCharacterCounter(textarea, studentId);
}
}
updateCharacterCounter(textarea, studentId) {
const counter = textarea.closest('[data-student-details]')?.querySelector('[data-char-counter]');
if (counter) {
const count = textarea.value.length;
counter.textContent = `${count} caractères`;
if (count > 500) {
counter.className = 'text-xs text-red-500';
} else if (count > 300) {
counter.className = 'text-xs text-yellow-500';
} else {
counter.className = 'text-xs text-gray-500';
}
}
}
queueSave(studentId, appreciation, immediate = false) {
const saveTask = {
studentId,
appreciation,
timestamp: Date.now(),
immediate
};
if (immediate) {
this.executeSave(saveTask);
} else {
// Remove previous queued save for this student
this.saveQueue = this.saveQueue.filter(task => task.studentId !== studentId);
this.saveQueue.push(saveTask);
this.processSaveQueue();
}
}
async processSaveQueue() {
if (this.isSaving || this.saveQueue.length === 0) return;
this.isSaving = true;
while (this.saveQueue.length > 0) {
const task = this.saveQueue.shift();
await this.executeSave(task);
await new Promise(resolve => setTimeout(resolve, 100)); // Throttle
}
this.isSaving = false;
}
async executeSave(saveTask) {
const { studentId, appreciation } = saveTask;
try {
this.showSavingState(studentId, true);
const response = await fetch(`/classes/${this.parent.classId}/council/appreciation/${studentId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
appreciation: appreciation,
trimester: this.parent.state.currentTrimester
})
});
const result = await response.json();
if (response.ok && result.success) {
this.showSavedState(studentId);
this.parent.state.modifiedAppreciations.delete(studentId);
this.updateLastModified(studentId, result.last_modified);
this.updateAppreciationStatus(studentId, result.has_content);
} else {
throw new Error(result.error || 'Erreur de sauvegarde');
}
} catch (error) {
console.error('Erreur sauvegarde appréciation:', error);
this.showErrorState(studentId, error.message);
this.parent.state.modifiedAppreciations.add(studentId);
this.parent.showToast(`Erreur sauvegarde`, 'error');
} finally {
this.showSavingState(studentId, false);
}
}
async finalizeAppreciation(studentId) {
const textarea = document.querySelector(`[data-appreciation-textarea][data-student-id="${studentId}"]`);
if (!textarea || !textarea.value.trim()) {
this.parent.showToast('Veuillez saisir une appréciation avant de finaliser', 'warning');
return;
}
if (confirm('Finaliser cette appréciation ? Elle ne pourra plus être modifiée.')) {
// Pour l'instant, on sauvegarde normalement. La finalisation sera une fonctionnalité future
this.queueSave(studentId, textarea.value, true);
}
}
showModifiedState(studentId, isModified) {
const indicator = document.querySelector(`[data-save-indicator="${studentId}"]`);
if (!indicator) return;
if (isModified) {
indicator.className = 'inline-flex items-center px-2 py-1 rounded-full text-xs bg-yellow-100 text-yellow-800';
indicator.innerHTML = '<span class="w-2 h-2 bg-yellow-400 rounded-full mr-1"></span>Modifié';
indicator.classList.remove('hidden');
}
}
showSavingState(studentId, isSaving) {
const indicator = document.querySelector(`[data-save-indicator="${studentId}"]`);
if (!indicator) return;
if (isSaving) {
indicator.className = 'inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800';
indicator.innerHTML = '<svg class="animate-spin w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Sauvegarde...';
indicator.classList.remove('hidden');
}
}
showSavedState(studentId) {
const indicator = document.querySelector(`[data-save-indicator="${studentId}"]`);
if (!indicator) return;
indicator.className = 'inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800';
indicator.innerHTML = '<span class="w-2 h-2 bg-green-400 rounded-full mr-1"></span>Sauvegardé';
setTimeout(() => {
indicator.classList.add('hidden');
}, 2000);
}
showErrorState(studentId, error) {
const indicator = document.querySelector(`[data-save-indicator="${studentId}"]`);
if (!indicator) return;
indicator.className = 'inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800';
indicator.innerHTML = '<span class="w-2 h-2 bg-red-400 rounded-full mr-1"></span>Erreur';
indicator.title = error;
indicator.classList.remove('hidden');
}
updateLastModified(studentId, lastModified) {
const element = document.querySelector(`[data-last-modified="${studentId}"]`);
if (element) {
const date = new Date(lastModified);
element.textContent = date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
}
updateAppreciationStatus(studentId, hasContent) {
const studentCard = document.querySelector(`[data-student-card="${studentId}"]`);
if (studentCard) {
studentCard.dataset.hasAppreciation = hasContent ? 'true' : 'false';
// Update status indicator in the card
const indicator = studentCard.querySelector('.inline-flex.items-center.px-2.py-1.rounded-full');
if (indicator) {
if (hasContent) {
indicator.className = 'inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800';
indicator.innerHTML = '<span class="w-2 h-2 bg-green-400 rounded-full mr-1"></span>Rédigée';
} else {
indicator.className = 'inline-flex items-center px-2 py-1 rounded-full text-xs bg-orange-100 text-orange-800';
indicator.innerHTML = '<span class="w-2 h-2 bg-orange-400 rounded-full mr-1"></span>À rédiger';
}
}
}
}
async saveAllPending() {
const textareas = document.querySelectorAll('[data-appreciation-textarea]');
for (const textarea of textareas) {
const studentId = textarea.dataset.studentId;
if (this.parent.state.modifiedAppreciations.has(studentId)) {
await this.executeSave({
studentId,
appreciation: textarea.value,
immediate: true
});
}
}
}
debounce(func, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
}
class UIManager {
constructor(councilPrep) {
this.parent = councilPrep;
}
init() {
this.bindCardToggles();
this.setupAccessibility();
}
bindCardToggles() {
const toggleButtons = document.querySelectorAll('[data-toggle-student]');
toggleButtons.forEach(button => {
button.addEventListener('click', (e) => {
const studentId = button.dataset.toggleStudent;
this.toggleStudentCard(studentId);
});
});
}
toggleStudentCard(studentId) {
const card = document.querySelector(`[data-student-card="${studentId}"]`);
const details = card?.querySelector(`[data-student-details="${studentId}"]`);
const icon = card?.querySelector('[data-toggle-icon]');
if (!card || !details) return;
const isExpanded = this.parent.state.expandedStudents.has(studentId);
if (isExpanded) {
this.collapseCard(details, icon);
this.parent.state.expandedStudents.delete(studentId);
} else {
this.expandCard(details, icon);
this.parent.state.expandedStudents.add(studentId);
// Auto-focus textarea
setTimeout(() => {
const textarea = details.querySelector('textarea');
textarea?.focus();
}, this.parent.options.animationDuration);
}
this.updateAccessibilityStates(card, !isExpanded);
}
expandCard(details, icon) {
details.classList.remove('hidden');
details.style.height = '0px';
details.style.opacity = '0';
// Force reflow
details.offsetHeight;
const targetHeight = details.scrollHeight;
details.style.transition = `height ${this.parent.options.animationDuration}ms ease-out, opacity ${this.parent.options.animationDuration}ms ease-out`;
details.style.height = `${targetHeight}px`;
details.style.opacity = '1';
if (icon) {
icon.style.transform = 'rotate(180deg)';
}
setTimeout(() => {
details.style.height = 'auto';
}, this.parent.options.animationDuration);
}
collapseCard(details, icon) {
const currentHeight = details.offsetHeight;
details.style.height = `${currentHeight}px`;
details.style.transition = `height ${this.parent.options.animationDuration}ms ease-out, opacity ${this.parent.options.animationDuration}ms ease-out`;
// Force reflow
details.offsetHeight;
details.style.height = '0px';
details.style.opacity = '0';
if (icon) {
icon.style.transform = 'rotate(0deg)';
}
setTimeout(() => {
details.classList.add('hidden');
details.style.height = '';
details.style.opacity = '';
details.style.transition = '';
}, this.parent.options.animationDuration);
}
setupAccessibility() {
const toggleButtons = document.querySelectorAll('[data-toggle-student]');
toggleButtons.forEach(button => {
button.setAttribute('role', 'button');
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-label', 'Afficher/Masquer les détails de l\'élève');
});
document.addEventListener('keydown', (e) => {
if (e.target.matches('[data-toggle-student]')) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.target.click();
}
}
});
}
updateAccessibilityStates(card, isExpanded) {
const button = card.querySelector('[data-toggle-student]');
if (button) {
button.setAttribute('aria-expanded', isExpanded.toString());
}
}
}
class FocusManager {
constructor(councilPrep) {
this.parent = councilPrep;
}
init() {
this.bindFocusModeToggle();
this.bindNavigationControls();
this.bindKeyboardShortcuts();
this.updateFilteredStudents();
}
bindFocusModeToggle() {
// Utiliser la délégation d'événement pour gérer plusieurs boutons
document.addEventListener('click', (e) => {
if (e.target.matches('[data-toggle-focus-mode]') || e.target.closest('[data-toggle-focus-mode]')) {
e.preventDefault();
this.toggleFocusMode();
}
});
}
bindNavigationControls() {
// Les boutons de navigation sont gérés ici dans la méthode globale
document.addEventListener('click', (e) => {
if (e.target.matches('[data-focus-prev]') || e.target.closest('[data-focus-prev]')) {
e.preventDefault();
this.navigatePrevious();
} else if (e.target.matches('[data-focus-next]') || e.target.closest('[data-focus-next]')) {
e.preventDefault();
this.navigateNext();
}
});
}
bindKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (!this.parent.state.isFocusMode) return;
switch (e.key) {
case 'Escape':
e.preventDefault();
this.toggleFocusMode(false);
break;
case 'ArrowLeft':
e.preventDefault();
this.navigatePrevious();
break;
case 'ArrowRight':
e.preventDefault();
this.navigateNext();
break;
}
});
}
toggleFocusMode(forcedState = null) {
const newState = forcedState !== null ? forcedState : !this.parent.state.isFocusMode;
this.parent.state.isFocusMode = newState;
if (newState) {
this.enterFocusMode();
} else {
this.exitFocusMode();
}
}
enterFocusMode() {
// Mettre à jour l'interface
this.updateFocusModeUI(true);
// Initialiser avec le premier élève
this.updateFilteredStudents();
if (this.parent.state.filteredStudents.length > 0) {
this.parent.state.focusCurrentIndex = 0;
this.showCurrentStudent();
}
// Sauvegarder l'état
localStorage.setItem('council-focus-mode', 'true');
}
exitFocusMode() {
// Mettre à jour l'interface
this.updateFocusModeUI(false);
// Réinitialiser l'état
this.parent.state.focusCurrentIndex = 0;
// Sauvegarder l'état
localStorage.removeItem('council-focus-mode');
}
updateFocusModeUI(isFocusMode) {
const elements = this.parent.elements;
if (isFocusMode) {
// Mode Focus : Interface minimaliste
// Ajouter classe au body pour styles globaux
document.body.classList.add('focus-mode');
// Masquer TOUS les éléments de liste
elements.listModeDisplay?.classList.add('hidden');
elements.listModeControls?.classList.add('hidden');
elements.listModeFilters?.classList.add('hidden');
elements.listModeActions?.classList.add('hidden');
elements.listModeHero?.classList.add('hidden');
elements.listModeBreadcrumb?.classList.add('hidden');
elements.listModeFiltersSection?.classList.add('hidden');
elements.listModeStats?.classList.add('hidden');
// Afficher uniquement les éléments focus
elements.focusModeDisplay?.classList.remove('hidden');
elements.focusModeHeader?.classList.remove('hidden');
elements.focusModeControls?.classList.add('hidden'); // Utiliser le header compact à la place
// Changer le texte du bouton
if (elements.focusModeText) {
elements.focusModeText.textContent = 'Mode Liste';
}
} else {
// Mode Liste : Interface complète
// Retirer classe du body
document.body.classList.remove('focus-mode');
// Réafficher tous les éléments de liste
elements.listModeDisplay?.classList.remove('hidden');
elements.listModeControls?.classList.remove('hidden');
elements.listModeFilters?.classList.remove('hidden');
elements.listModeActions?.classList.remove('hidden');
elements.listModeHero?.classList.remove('hidden');
elements.listModeBreadcrumb?.classList.remove('hidden');
elements.listModeFiltersSection?.classList.remove('hidden');
elements.listModeStats?.classList.remove('hidden');
// Masquer les éléments focus
elements.focusModeDisplay?.classList.add('hidden');
elements.focusModeHeader?.classList.add('hidden');
elements.focusModeControls?.classList.remove('hidden');
// Changer le texte du bouton
if (elements.focusModeText) {
elements.focusModeText.textContent = 'Mode Focus';
}
}
}
updateFilteredStudents() {
// En mode focus, on prend TOUS les élèves (pas de filtres)
const allStudents = Array.from(document.querySelectorAll('[data-student-card]'));
this.parent.state.filteredStudents = allStudents;
// Mettre à jour le compteur total
if (this.parent.elements.focusTotalSpan) {
this.parent.elements.focusTotalSpan.textContent = allStudents.length;
}
}
showCurrentStudent() {
const currentStudent = this.parent.state.filteredStudents[this.parent.state.focusCurrentIndex];
if (!currentStudent) return;
// Cloner l'élève et l'afficher dans le conteneur focus
const focusContainer = this.parent.elements.focusModeDisplay;
if (!focusContainer) return;
// 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);
// Forcer l'expansion de l'appréciation en mode focus
const detailsSection = clonedStudent.querySelector('[data-student-details]');
if (detailsSection) {
detailsSection.classList.remove('hidden');
detailsSection.style.height = 'auto';
detailsSection.style.opacity = '1';
detailsSection.style.flex = '1';
detailsSection.style.display = 'block';
detailsSection.style.overflowY = 'auto';
// S'assurer que le contenu interne utilise Flexbox
const innerContent = detailsSection.querySelector('.px-6.py-6.space-y-6');
if (innerContent) {
innerContent.style.display = 'flex';
innerContent.style.flexDirection = 'column';
innerContent.style.height = '100%';
innerContent.style.gap = '1.5rem';
}
}
// Ajouter une classe spéciale pour le mode focus
clonedStudent.classList.add('focus-mode-student');
// Ajouter au conteneur
focusContainer.appendChild(clonedStudent);
// Mettre à jour l'indicateur de position
this.updatePositionIndicator();
// Mettre à jour les boutons de navigation
this.updateNavigationButtons();
// Réattacher TOUS les événements pour le nouvel élément focus
this.bindFocusStudentEvents(clonedStudent, studentId);
// Assurer que toutes les sections sont visibles
this.ensureAllSectionsVisible(clonedStudent);
// Focus automatique sur le textarea de l'appréciation
this.focusAppreciationTextarea(clonedStudent);
// Optimiser la hauteur pour éviter le scroll
this.optimizeHeight();
}
bindFocusStudentEvents(clonedStudent, studentId) {
// 1. Événements textarea avec synchronisation bidirectionnelle
const textarea = clonedStudent.querySelector(`[data-appreciation-textarea][data-student-id="${studentId}"]`);
if (textarea) {
// Handler de sauvegarde avec synchronisation
const saveHandler = this.parent.autoSaveManager.debounce(() => {
this.saveFocusAppreciation(studentId, textarea.value);
}, this.parent.options.debounceTime);
// Événement input avec sync
textarea.addEventListener('input', (e) => {
this.parent.state.modifiedAppreciations.add(studentId);
this.parent.autoSaveManager.updateCharacterCounter(textarea, studentId);
this.syncAppreciationToOriginal(studentId, e.target.value);
saveHandler();
});
// Événement blur avec sauvegarde immédiate
textarea.addEventListener('blur', () => {
if (this.parent.state.modifiedAppreciations.has(studentId)) {
this.saveFocusAppreciation(studentId, textarea.value, true);
}
});
// Setup character counter
this.parent.autoSaveManager.setupCharacterCounter(textarea, studentId);
}
// 2. Boutons supprimés - auto-sauvegarde uniquement
// 4. Gestion des barres de progression
this.setupProgressBars(clonedStudent);
}
ensureAllSectionsVisible(clonedStudent) {
// S'assurer que les sections compétences/domaines sont visibles
const competenceSection = clonedStudent.querySelector('.competence-domain-section');
if (competenceSection) {
competenceSection.style.display = 'block';
competenceSection.style.minHeight = '200px';
competenceSection.style.flexShrink = '0';
}
// S'assurer que les barres de progression sont configurées
const progressBars = clonedStudent.querySelectorAll('.progress-bar-container');
progressBars.forEach(bar => {
bar.style.display = 'block';
});
// Section info supprimée - maintenant intégrée dans la zone d'appréciation
// S'assurer que les résultats d'évaluation sont visibles
const evaluationResults = clonedStudent.querySelector('.evaluation-results');
if (evaluationResults) {
evaluationResults.style.display = 'block';
}
// S'assurer que la section progress-bars est visible
const progressBarsSection = clonedStudent.querySelector('.progress-bars');
if (progressBarsSection) {
progressBarsSection.style.display = 'block';
}
}
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
}
async saveFocusAppreciation(studentId, appreciation, immediate = false) {
try {
// Afficher l'état de sauvegarde
this.showFocusSavingState(studentId, true);
const response = await fetch(`/classes/${this.parent.classId}/council/appreciation/${studentId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
appreciation: appreciation,
trimester: this.parent.state.currentTrimester
})
});
const result = await response.json();
if (response.ok && result.success) {
this.showFocusSavedState(studentId);
this.parent.state.modifiedAppreciations.delete(studentId);
// Synchroniser avec l'élément original
this.syncAppreciationStatusToOriginal(studentId, result.has_content);
this.syncLastModifiedToOriginal(studentId, result.last_modified);
// Toast de confirmation
if (immediate) {
this.parent.showToast('Appréciation sauvegardée', 'success');
}
} else {
throw new Error(result.error || 'Erreur de sauvegarde');
}
} catch (error) {
console.error('❌ Erreur sauvegarde appréciation focus:', error);
this.showFocusErrorState(studentId, error.message);
this.parent.state.modifiedAppreciations.add(studentId);
this.parent.showToast('Erreur de sauvegarde', 'error');
} finally {
this.showFocusSavingState(studentId, false);
}
}
syncAppreciationToOriginal(studentId, value) {
// Synchroniser le texte avec l'élément original dans la liste
const originalTextarea = document.querySelector(`[data-student-card="${studentId}"] [data-appreciation-textarea]`);
if (originalTextarea && originalTextarea.value !== value) {
originalTextarea.value = value;
}
}
syncAppreciationStatusToOriginal(studentId, hasContent) {
// Synchroniser l'état d'appréciation avec l'élément original
const originalCard = document.querySelector(`[data-student-card="${studentId}"]`);
if (originalCard) {
originalCard.dataset.hasAppreciation = hasContent ? 'true' : 'false';
// Mettre à jour l'indicateur de statut
const indicator = originalCard.querySelector('.inline-flex.items-center.px-2.py-1.rounded-full');
if (indicator) {
if (hasContent) {
indicator.className = 'inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800';
indicator.innerHTML = '<span class="w-2 h-2 bg-green-400 rounded-full mr-1"></span>Rédigée';
} else {
indicator.className = 'inline-flex items-center px-2 py-1 rounded-full text-xs bg-orange-100 text-orange-800';
indicator.innerHTML = '<span class="w-2 h-2 bg-orange-400 rounded-full mr-1"></span>À rédiger';
}
}
}
}
syncLastModifiedToOriginal(studentId, lastModified) {
const originalElement = document.querySelector(`[data-student-card="${studentId}"] [data-last-modified="${studentId}"]`);
if (originalElement) {
const date = new Date(lastModified);
originalElement.textContent = date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
}
showFocusSavingState(studentId, isSaving) {
const indicator = document.querySelector(`[data-focus-clone-of="${studentId}"] [data-save-indicator="${studentId}"]`);
if (!indicator) return;
if (isSaving) {
indicator.className = 'inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800';
indicator.innerHTML = '<svg class="animate-spin w-3 h-3 mr-1" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Sauvegarde...';
indicator.classList.remove('hidden');
}
}
showFocusSavedState(studentId) {
const indicator = document.querySelector(`[data-focus-clone-of="${studentId}"] [data-save-indicator="${studentId}"]`);
if (!indicator) return;
indicator.className = 'inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800';
indicator.innerHTML = '<span class="w-2 h-2 bg-green-400 rounded-full mr-1"></span>Sauvegardé';
setTimeout(() => {
indicator.classList.add('hidden');
}, 2000);
}
showFocusErrorState(studentId, error) {
const indicator = document.querySelector(`[data-focus-clone-of="${studentId}"] [data-save-indicator="${studentId}"]`);
if (!indicator) return;
indicator.className = 'inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800';
indicator.innerHTML = '<span class="w-2 h-2 bg-red-400 rounded-full mr-1"></span>Erreur';
indicator.title = error;
indicator.classList.remove('hidden');
}
focusAppreciationTextarea(clonedStudent) {
// Attendre que l'élément soit complètement rendu
setTimeout(() => {
const textarea = clonedStudent.querySelector('[data-appreciation-textarea]');
if (textarea) {
textarea.focus();
// Positionner le curseur à la fin du texte existant
const textLength = textarea.value.length;
textarea.setSelectionRange(textLength, textLength);
// Scroll smooth vers le textarea si nécessaire
textarea.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
}
}, 100); // Délai pour s'assurer que l'animation est terminée
}
updatePositionIndicator() {
if (this.parent.elements.focusCurrentSpan) {
this.parent.elements.focusCurrentSpan.textContent = this.parent.state.focusCurrentIndex + 1;
}
}
updateNavigationButtons() {
const prevBtn = this.parent.elements.focusPrevBtn;
const nextBtn = this.parent.elements.focusNextBtn;
const currentIndex = this.parent.state.focusCurrentIndex;
const totalStudents = this.parent.state.filteredStudents.length;
if (prevBtn) {
prevBtn.disabled = currentIndex <= 0;
}
if (nextBtn) {
nextBtn.disabled = currentIndex >= totalStudents - 1;
}
}
navigatePrevious() {
if (this.parent.state.focusCurrentIndex > 0) {
this.parent.state.focusCurrentIndex--;
this.showCurrentStudent();
}
}
navigateNext() {
if (this.parent.state.focusCurrentIndex < this.parent.state.filteredStudents.length - 1) {
this.parent.state.focusCurrentIndex++;
this.showCurrentStudent();
}
}
optimizeHeight() {
const focusContainer = this.parent.elements.focusModeDisplay;
if (!focusContainer) return;
const student = focusContainer.querySelector('.focus-mode-student');
if (!student) return;
// Forcer explicitement la hauteur complète
student.style.height = '100%';
student.style.minHeight = '100%';
student.style.maxHeight = 'none';
student.style.display = 'flex';
student.style.flexDirection = 'column';
student.style.overflow = 'hidden';
// S'assurer que le header garde sa taille et ne grandit pas
const headerContainer = student.querySelector('.px-6.py-4');
if (headerContainer) {
headerContainer.style.flexShrink = '0';
headerContainer.style.flex = 'none';
}
// S'assurer que la section détails utilise tout l'espace restant
const detailsSection = student.querySelector('[data-student-details]');
if (detailsSection) {
detailsSection.style.flex = '1 1 0';
detailsSection.style.height = '0'; // Force flexbox
detailsSection.style.display = 'block';
detailsSection.style.overflowY = 'auto';
detailsSection.style.minHeight = '0';
}
// Scroll vers le haut si nécessaire
window.scrollTo(0, 0);
// Debug des hauteurs
const containerHeight = focusContainer.offsetHeight;
const studentHeight = student.offsetHeight;
}
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
if (this.parent.state.isFocusMode) {
return;
}
}
}
// Auto-initialization
document.addEventListener('DOMContentLoaded', () => {
const councilContainer = document.querySelector('[data-council-preparation]');
if (councilContainer) {
const classId = councilContainer.dataset.classId;
if (classId) {
window.currentCouncilPreparation = new CouncilPreparation(parseInt(classId));
}
}
});
// Global export
window.CouncilPreparation = CouncilPreparation;