Files
notytex/static/js/CouncilPreparation.js

1376 lines
53 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();
console.log('✅ CouncilPreparation initialized successfully');
console.log('🎯 Focus Manager ready:', !!this.focusManager);
} 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;
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
this.parent.elements.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() {
const saveButtons = document.querySelectorAll('[data-save-manual]');
const finalizeButtons = document.querySelectorAll('[data-finalize]');
saveButtons.forEach(button => {
const studentId = button.dataset.saveManual;
button.addEventListener('click', () => {
const textarea = document.querySelector(`[data-appreciation-textarea][data-student-id="${studentId}"]`);
if (textarea) {
this.queueSave(studentId, textarea.value, true);
}
});
});
finalizeButtons.forEach(button => {
const studentId = button.dataset.finalize;
button.addEventListener('click', () => {
this.finalizeAppreciation(studentId);
});
});
}
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]')) {
console.log('🎯 Bouton Mode Focus cliqué !');
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;
console.log(`🔄 Basculement vers mode: ${newState ? 'FOCUS' : 'LISTE'}`);
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');
console.log('✨ Mode focus activé - Focus sur première appréciation');
}
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 = '';
// Cloner l'élément élève
const clonedStudent = currentStudent.cloneNode(true);
// 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';
}
// 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);
// Focus automatique sur le textarea de l'appréciation
this.focusAppreciationTextarea(clonedStudent);
// Optimiser la hauteur pour éviter le scroll
this.optimizeHeight();
}
bindFocusStudentEvents(clonedStudent, studentId) {
console.log(`🔧 Attachement des événements pour l'élève ${studentId} en mode focus`);
// 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(() => {
console.log(`💾 Sauvegarde en focus pour élève ${studentId}`);
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)) {
console.log(`💾 Sauvegarde blur en focus pour élève ${studentId}`);
this.saveFocusAppreciation(studentId, textarea.value, true);
}
});
// Setup character counter
this.parent.autoSaveManager.setupCharacterCounter(textarea, studentId);
}
// 2. Boutons de sauvegarde manuelle
const saveButton = clonedStudent.querySelector(`[data-save-manual="${studentId}"]`);
if (saveButton) {
saveButton.addEventListener('click', () => {
const textareaValue = clonedStudent.querySelector(`[data-appreciation-textarea][data-student-id="${studentId}"]`)?.value || '';
console.log(`💾 Sauvegarde manuelle en focus pour élève ${studentId}`);
this.saveFocusAppreciation(studentId, textareaValue, true);
});
}
// 3. Bouton de finalisation
const finalizeButton = clonedStudent.querySelector(`[data-finalize="${studentId}"]`);
if (finalizeButton) {
finalizeButton.addEventListener('click', () => {
const textareaValue = clonedStudent.querySelector(`[data-appreciation-textarea][data-student-id="${studentId}"]`)?.value || '';
if (!textareaValue.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.')) {
console.log(`✅ Finalisation en focus pour élève ${studentId}`);
this.saveFocusAppreciation(studentId, textareaValue, true);
}
});
}
}
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) {
console.log(`✅ Sauvegarde réussie en focus pour élève ${studentId}`);
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) {
console.log('🎯 Focus automatique sur le textarea d\'appréciation');
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();
console.log('⬅️ Navigation vers élève précédent avec focus sur appréciation');
}
}
navigateNext() {
if (this.parent.state.focusCurrentIndex < this.parent.state.filteredStudents.length - 1) {
this.parent.state.focusCurrentIndex++;
this.showCurrentStudent();
console.log('➡️ Navigation vers élève suivant avec focus sur appréciation');
}
}
optimizeHeight() {
const focusContainer = this.parent.elements.focusModeDisplay;
if (!focusContainer) return;
const student = focusContainer.querySelector('.focus-mode-student');
if (!student) return;
// Calculer la hauteur disponible
const windowHeight = window.innerHeight;
const headerHeight = 200; // Approximation header + navigation + contrôles
const maxHeight = windowHeight - headerHeight;
// Ajuster la hauteur de la carte
student.style.maxHeight = `${maxHeight}px`;
// Scroll vers le haut si nécessaire
window.scrollTo(0, 0);
}
// 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;