refact: unify js and css
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
# 📚 Documentation Frontend - Notytex
|
||||
|
||||
> **Design System & Composants UI**
|
||||
> **Version**: 2.0
|
||||
> **Dernière mise à jour**: 7 août 2025
|
||||
> **Version**: 2.1
|
||||
> **Dernière mise à jour**: 16 août 2025
|
||||
|
||||
## 🎯 **Vue d'Ensemble**
|
||||
|
||||
Cette documentation couvre l'ensemble du **design system Notytex**, ses composants UI, et les bonnes pratiques pour maintenir une interface cohérente et moderne.
|
||||
|
||||
**Notytex 2.1** intègre désormais une **architecture JavaScript moderne unifiée** avec des modules ES6+, un système de gestion d'état réactif, et des composants optimisés pour les performances.
|
||||
|
||||
---
|
||||
|
||||
## 📁 **Organisation de la Documentation**
|
||||
@@ -41,13 +43,16 @@ Cette documentation couvre l'ensemble du **design system Notytex**, ses composan
|
||||
| Common Macros | Macros réutilisables (hero_section, buttons...) | 📋 |
|
||||
| **Form Components** | **Champs de formulaire standardisés (voir CLASS_FORM.md)** | ✅ |
|
||||
|
||||
### ⚡ **Performance & Outils**
|
||||
### ⚡ **JavaScript & Architecture**
|
||||
|
||||
| Document | Description | Statut |
|
||||
| ---------------------- | ------------------------------------------- | ------ |
|
||||
| **Architecture JavaScript** | **Système unifié ES6+ avec NotytexCore & modules** | ✅ |
|
||||
| **Class Dashboard JS** | **Module dashboard moderne avec gestion d'état** | ✅ |
|
||||
| **Council Preparation JS** | **Module conseil de classe avec mode focus** | ✅ |
|
||||
| **Filter System JS** | **Gestionnaire de filtres réactif** | ✅ |
|
||||
| Performance Guidelines | Optimisation frontend & best practices | 📋 |
|
||||
| Testing Strategy | Tests visuels, accessibilité, cross-browser | 📋 |
|
||||
| Build & Deploy | Process de build frontend | 📋 |
|
||||
|
||||
---
|
||||
|
||||
@@ -75,6 +80,50 @@ Cette documentation couvre l'ensemble du **design system Notytex**, ses composan
|
||||
|
||||
---
|
||||
|
||||
## ⚡ **Architecture JavaScript Moderne**
|
||||
|
||||
### **Système Unifié NotytexCore**
|
||||
|
||||
```javascript
|
||||
// Architecture ES6+ avec modules dynamiques
|
||||
NotytexCore.getInstance() // Singleton pattern
|
||||
├── Modules dynamiques (loadPageModule())
|
||||
├── Gestion d'état réactif (Proxy-based)
|
||||
├── Système d'événements unifié
|
||||
└── Utilitaires performance (debounce, throttle)
|
||||
```
|
||||
|
||||
### **Modules Principaux**
|
||||
|
||||
| Module | Description | Fonctionnalités |
|
||||
|--------|-------------|-----------------|
|
||||
| **NotytexCore** | Cœur du système JS | État réactif, événements, modules |
|
||||
| **ClassDashboard** | Dashboard classe | Stats temps réel, graphiques, filtres |
|
||||
| **CouncilPreparation** | Conseil de classe | Mode focus, auto-save, navigation |
|
||||
| **FilterManager** | Gestionnaire filtres | Filtres dynamiques, URL sync |
|
||||
| **NotificationSystem** | Notifications | Toasts, alertes, confirmations |
|
||||
| **ModalManager** | Modales | Overlay, gestion focus, accessible |
|
||||
|
||||
### **Patterns Utilisés**
|
||||
|
||||
```javascript
|
||||
// Gestion d'état réactif
|
||||
const state = new Proxy({}, {
|
||||
set(target, property, value) {
|
||||
// Auto-notification des observers
|
||||
this.notifyObservers(property, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Chargement dynamique
|
||||
const module = await NotytexCore.loadModule('ClassDashboard', './ClassDashboard.js');
|
||||
|
||||
// Événements unifiés
|
||||
NotytexCore.emit('stats:updated', { trimester: 2, data: stats });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 **Design System en Bref**
|
||||
|
||||
### **Couleurs Principales**
|
||||
@@ -117,6 +166,7 @@ xl: 1280px // Large desktop
|
||||
|
||||
### **✅ Complété (100%)**
|
||||
|
||||
**Interface & Composants UI :**
|
||||
- Guide des bonnes pratiques générales
|
||||
- Page des classes (refonte complète)
|
||||
- **Formulaire de classe (création/modification complet)**
|
||||
@@ -125,19 +175,38 @@ xl: 1280px // Large desktop
|
||||
- Filtres des évaluations
|
||||
- Cartes d'évaluation
|
||||
|
||||
**Architecture JavaScript :**
|
||||
- **Système NotytexCore unifié (ES6+)**
|
||||
- **ClassDashboard.js - Module dashboard avancé**
|
||||
- **CouncilPreparation.js - Module conseil avec mode focus**
|
||||
- **Gestionnaire de filtres réactif**
|
||||
- **Système de notifications moderne**
|
||||
- **Gestionnaire de modales accessible**
|
||||
|
||||
### **🔄 En cours (0-80%)**
|
||||
|
||||
- Design tokens centralisés
|
||||
- Guidelines d'accessibilité
|
||||
- Guidelines d'accessibilité
|
||||
- Tests automatisés frontend
|
||||
- **Documentation technique des modules JS** (architecture interne)
|
||||
|
||||
### **📋 À faire**
|
||||
|
||||
- Documentation page Dashboard
|
||||
**Interface & Pages :**
|
||||
- Documentation page Dashboard principal
|
||||
- Documentation gestion des élèves
|
||||
- Composants de formulaire
|
||||
- Guide de performance
|
||||
- Process de build/deploy
|
||||
- Composants de formulaire (documentation complète)
|
||||
|
||||
**JavaScript & Performance :**
|
||||
- Guide de performance frontend
|
||||
- Optimisations Webpack/Vite
|
||||
- Service Workers & Cache Strategy
|
||||
- Bundle analysis & Tree shaking
|
||||
|
||||
**DevOps & Build :**
|
||||
- Process de build/deploy automatisé
|
||||
- Tests end-to-end avec Playwright
|
||||
- Monitoring des métriques Web Vitals
|
||||
|
||||
---
|
||||
|
||||
@@ -145,6 +214,7 @@ xl: 1280px // Large desktop
|
||||
|
||||
### **Tests Rapides**
|
||||
|
||||
**Templates Jinja2 :**
|
||||
```bash
|
||||
# Validation syntaxe tous les templates
|
||||
find templates/ -name "*.html" -exec echo "Testing {}" \;
|
||||
@@ -164,13 +234,39 @@ with app.app_context():
|
||||
"
|
||||
```
|
||||
|
||||
**Modules JavaScript :**
|
||||
```bash
|
||||
# Validation syntaxe ES6+
|
||||
node --check static/js/notytex-core.js
|
||||
node --check static/js/ClassDashboard.js
|
||||
node --check static/js/CouncilPreparation.js
|
||||
|
||||
# Test d'importation des modules
|
||||
node -e "
|
||||
import('./static/js/notytex-core.js')
|
||||
.then(() => console.log('✅ NotytexCore module OK'))
|
||||
.catch(err => console.log('❌ NotytexCore error:', err.message))
|
||||
"
|
||||
```
|
||||
|
||||
### **Tests Complets**
|
||||
|
||||
- **Syntaxe** : Tous templates Jinja2 valides
|
||||
- **Responsive** : Tests sur tous breakpoints
|
||||
**Frontend Statique :**
|
||||
- **Syntaxe** : Tous templates Jinja2 valides ✅
|
||||
- **Responsive** : Tests sur tous breakpoints ✅
|
||||
- **Accessibilité** : Tests axe-core + validation manuelle
|
||||
- **Cross-browser** : Chrome, Firefox, Safari, Edge
|
||||
|
||||
**JavaScript Modules :**
|
||||
- **Syntaxe ES6+** : Tous modules validés sans erreurs ✅
|
||||
- **Imports/Exports** : Résolution correcte des dépendances ✅
|
||||
- **Gestion d'erreur** : Try/catch pour modules critiques ✅
|
||||
- **Performance** : Lazy loading & debouncing implémentés ✅
|
||||
|
||||
**Intégration :**
|
||||
- **Performance** : Métriques Lighthouse > 90
|
||||
- **Functional Testing** : Tests E2E des flows critiques
|
||||
- **Error Handling** : Fallbacks gracieux en cas d'échec JS
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -171,11 +171,30 @@ def dashboard(id):
|
||||
if not class_group:
|
||||
abort(404)
|
||||
|
||||
# Récupérer aussi les statistiques pour injection dans le template
|
||||
stats_data = {}
|
||||
try:
|
||||
# Construction des données comme dans l'API
|
||||
quantity_stats = class_group.get_trimester_statistics(trimester)
|
||||
domain_analysis = class_group.get_domain_analysis(trimester)
|
||||
competence_analysis = class_group.get_competence_analysis(trimester)
|
||||
class_results = class_group.get_class_results(trimester)
|
||||
|
||||
stats_data = {
|
||||
'summary': quantity_stats,
|
||||
'domains': domain_analysis,
|
||||
'competences': competence_analysis,
|
||||
'results': class_results
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f'Erreur lors du chargement des stats dashboard: {e}')
|
||||
|
||||
current_app.logger.debug(f'Dashboard classe {id} affiché pour trimestre {trimester}')
|
||||
|
||||
return render_template('class_dashboard.html',
|
||||
class_group=class_group,
|
||||
selected_trimester=trimester)
|
||||
selected_trimester=trimester,
|
||||
stats_data=stats_data)
|
||||
|
||||
@bp.route('/<int:id>/stats')
|
||||
@handle_db_errors
|
||||
|
||||
@@ -103,6 +103,65 @@
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
/* Layout utilitaires uniformes */
|
||||
.container-responsive {
|
||||
@apply max-w-7xl mx-auto px-4 py-8;
|
||||
}
|
||||
|
||||
.container-narrow {
|
||||
@apply max-w-4xl mx-auto px-4 py-6;
|
||||
}
|
||||
|
||||
.container-wide {
|
||||
@apply max-w-full mx-auto px-6 py-8;
|
||||
}
|
||||
|
||||
/* Spacing système unifié */
|
||||
.space-section {
|
||||
@apply space-y-8;
|
||||
}
|
||||
|
||||
.space-elements {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.space-items {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.space-tight {
|
||||
@apply space-y-3;
|
||||
}
|
||||
|
||||
.space-compact {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
/* Tailles de texte système */
|
||||
.text-display {
|
||||
@apply text-4xl font-bold leading-tight;
|
||||
}
|
||||
|
||||
.text-heading {
|
||||
@apply text-2xl font-bold leading-snug;
|
||||
}
|
||||
|
||||
.text-subheading {
|
||||
@apply text-xl font-semibold leading-normal;
|
||||
}
|
||||
|
||||
.text-body {
|
||||
@apply text-base leading-relaxed;
|
||||
}
|
||||
|
||||
.text-caption {
|
||||
@apply text-sm text-gray-600 leading-normal;
|
||||
}
|
||||
|
||||
.text-micro {
|
||||
@apply text-xs text-gray-500 leading-tight;
|
||||
}
|
||||
|
||||
/* Gradients réutilisables */
|
||||
.bg-gradient-primary {
|
||||
background: var(--gradient-primary);
|
||||
@@ -211,6 +270,48 @@
|
||||
@apply block bg-white rounded-lg shadow hover:shadow-lg transition-all duration-300 p-6 transform hover:scale-105 group;
|
||||
}
|
||||
|
||||
/* Grilles système uniformes */
|
||||
.grid-responsive {
|
||||
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6;
|
||||
}
|
||||
|
||||
.grid-actions {
|
||||
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4;
|
||||
}
|
||||
|
||||
.grid-cards {
|
||||
@apply grid grid-cols-1 lg:grid-cols-2 gap-6;
|
||||
}
|
||||
|
||||
.grid-list {
|
||||
@apply grid grid-cols-1 gap-4;
|
||||
}
|
||||
|
||||
/* Layouts de page standardisés */
|
||||
.page-layout {
|
||||
@apply min-h-screen bg-gray-100;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
@apply container-responsive space-section;
|
||||
}
|
||||
|
||||
.page-section {
|
||||
@apply bg-white rounded-xl shadow-lg overflow-hidden;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@apply px-6 py-4 border-b border-gray-200 flex items-center justify-between;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
@apply px-6 py-4 border-t border-gray-200 bg-gray-50;
|
||||
}
|
||||
|
||||
/* Filtres */
|
||||
.filter-section {
|
||||
@apply bg-white rounded-lg shadow p-6;
|
||||
@@ -233,7 +334,7 @@
|
||||
ANIMATIONS ET EFFETS
|
||||
======================================== */
|
||||
|
||||
/* Animation d'apparition */
|
||||
/* Animations d'apparition standardisées */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -245,10 +346,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Classes d'animation standardisées */
|
||||
.fade-in-up {
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
.fade-in-left {
|
||||
animation: fadeInLeft 0.4s ease-out;
|
||||
}
|
||||
|
||||
.fade-in-right {
|
||||
animation: fadeInRight 0.4s ease-out;
|
||||
}
|
||||
|
||||
.scale-in {
|
||||
animation: scaleIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Animations au scroll */
|
||||
.animate-on-scroll {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: all 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-on-scroll.in-view {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Micro-interactions */
|
||||
.hover-lift {
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.hover-scale:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Animation des messages flash */
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
|
||||
@@ -10,7 +10,7 @@ class ClassDashboard {
|
||||
this.options = {
|
||||
debounceTime: 300,
|
||||
cacheTimeout: 5 * 60 * 1000, // 5 minutes
|
||||
animationDuration: Notytex.config.transitions.normal,
|
||||
animationDuration: 300, // ms
|
||||
enableTouchGestures: true,
|
||||
...options
|
||||
};
|
||||
@@ -44,6 +44,39 @@ class ClassDashboard {
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilitaires pour remplacer l'ancien système Notytex
|
||||
*/
|
||||
static utils = {
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
},
|
||||
|
||||
showToast(message, type = 'info', duration = 3000) {
|
||||
// Implémentation simple de toast
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed top-4 right-4 px-4 py-2 rounded-lg text-white z-50 transition-opacity duration-300 ${
|
||||
type === 'success' ? 'bg-green-500' :
|
||||
type === 'error' ? 'bg-red-500' : 'bg-blue-500'
|
||||
}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => document.body.removeChild(toast), 300);
|
||||
}, duration);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialisation du dashboard
|
||||
*/
|
||||
@@ -122,8 +155,15 @@ class ClassDashboard {
|
||||
* Charge les données initiales
|
||||
*/
|
||||
async loadInitialData() {
|
||||
const trimester = this.state.currentTrimester;
|
||||
return await this.fetchStats(trimester);
|
||||
// Utiliser les données injectées si disponibles, sinon charger via AJAX
|
||||
if (window.initialStatsData && Object.keys(window.initialStatsData).length > 0) {
|
||||
this.updateStatsUI(window.initialStatsData);
|
||||
return window.initialStatsData;
|
||||
} else {
|
||||
// Charger le trimestre par défaut ou depuis l'URL
|
||||
const trimester = this.state.currentTrimester;
|
||||
return await this.fetchStats(trimester);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,7 +182,7 @@ class ClassDashboard {
|
||||
document.addEventListener('keydown', this.handleKeyboardNavigation.bind(this));
|
||||
|
||||
// Gestion du redimensionnement
|
||||
const debouncedResize = Notytex.utils.debounce(
|
||||
const debouncedResize = ClassDashboard.utils.debounce(
|
||||
this.handleResize.bind(this),
|
||||
this.options.debounceTime
|
||||
);
|
||||
@@ -224,7 +264,7 @@ class ClassDashboard {
|
||||
|
||||
// Notification de succès
|
||||
const trimesterName = trimester ? `Trimestre ${trimester}` : 'Vue globale';
|
||||
Notytex.utils.showToast(`${trimesterName} chargé`, 'success', 1500);
|
||||
ClassDashboard.utils.showToast(`${trimesterName} chargé`, 'success', 1500);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -413,11 +453,17 @@ class ClassDashboard {
|
||||
* Mise à jour de l'interface utilisateur avec les nouvelles données
|
||||
*/
|
||||
updateStatsUI(statsData) {
|
||||
// Mise à jour des cards de domaines
|
||||
this.updateDomainsCard(statsData.domains);
|
||||
// Mise à jour des cards de domaines - vérifier la structure des données
|
||||
if (statsData.domains) {
|
||||
const domainsArray = statsData.domains.domains || statsData.domains;
|
||||
this.updateDomainsCard(domainsArray);
|
||||
}
|
||||
|
||||
// Mise à jour des cards de compétences
|
||||
this.updateCompetencesCard(statsData.competences);
|
||||
// Mise à jour des cards de compétences - vérifier la structure des données
|
||||
if (statsData.competences) {
|
||||
const competencesArray = statsData.competences.competences || statsData.competences;
|
||||
this.updateCompetencesCard(competencesArray);
|
||||
}
|
||||
|
||||
// Mise à jour des résultats
|
||||
if (statsData.results) {
|
||||
@@ -436,22 +482,25 @@ class ClassDashboard {
|
||||
const card = document.querySelector('[data-stats-card="domains"]');
|
||||
if (!card || !domainsData) return;
|
||||
|
||||
// S'assurer que c'est un tableau
|
||||
const domains = Array.isArray(domainsData) ? domainsData : [];
|
||||
|
||||
// Mettre à jour le compteur
|
||||
const countElement = card.querySelector('[data-domains-count]');
|
||||
if (countElement) {
|
||||
this.animateNumber(countElement, domainsData.length);
|
||||
this.animateNumber(countElement, domains.length);
|
||||
}
|
||||
|
||||
// Mettre à jour la liste
|
||||
const container = card.querySelector('[data-domains-list]');
|
||||
if (!container) return;
|
||||
|
||||
if (domainsData.length === 0) {
|
||||
if (domains.length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-gray-500 text-sm py-4">Aucun domaine évalué</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = domainsData.map(domain => `
|
||||
container.innerHTML = domains.map(domain => `
|
||||
<div class="bg-green-50 rounded-lg p-3 border border-green-100">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center">
|
||||
@@ -474,22 +523,25 @@ class ClassDashboard {
|
||||
const card = document.querySelector('[data-stats-card="competences"]');
|
||||
if (!card || !competencesData) return;
|
||||
|
||||
// S'assurer que c'est un tableau
|
||||
const competences = Array.isArray(competencesData) ? competencesData : [];
|
||||
|
||||
// Mettre à jour le compteur
|
||||
const countElement = card.querySelector('[data-competences-count]');
|
||||
if (countElement) {
|
||||
this.animateNumber(countElement, competencesData.length);
|
||||
this.animateNumber(countElement, competences.length);
|
||||
}
|
||||
|
||||
// Mettre à jour la liste
|
||||
const container = card.querySelector('[data-competences-list]');
|
||||
if (!container) return;
|
||||
|
||||
if (competencesData.length === 0) {
|
||||
if (competences.length === 0) {
|
||||
container.innerHTML = '<div class="text-center text-gray-500 text-sm py-4">Aucune compétence évaluée</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = competencesData.map(competence => `
|
||||
container.innerHTML = competences.map(competence => `
|
||||
<div class="bg-purple-50 rounded-lg p-3 border border-purple-100">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center">
|
||||
@@ -1125,7 +1177,7 @@ class ClassDashboard {
|
||||
this.elements.errorContainer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
Notytex.utils.showToast(message, 'error');
|
||||
ClassDashboard.utils.showToast(message, 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1428,12 +1480,14 @@ class ClassDashboard {
|
||||
threshold: 0.1
|
||||
});
|
||||
|
||||
// Observer les cards non visibles
|
||||
this.elements.statsCards.forEach(card => {
|
||||
if (!this.isElementInViewport(card)) {
|
||||
this.state.intersectionObserver.observe(card);
|
||||
}
|
||||
});
|
||||
// Observer les cards non visibles - vérification de sécurité
|
||||
if (this.elements.statsCards && this.elements.statsCards.length > 0) {
|
||||
this.elements.statsCards.forEach(card => {
|
||||
if (!this.isElementInViewport(card)) {
|
||||
this.state.intersectionObserver.observe(card);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1564,7 +1618,7 @@ class ClassDashboard {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Erreur lors du préchargement:', error);
|
||||
// Erreur silencieuse du préchargement
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1587,7 +1641,7 @@ class ClassDashboard {
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('Erreur lors du préchargement des détails:', error);
|
||||
// Erreur silencieuse du préchargement des détails
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1797,7 +1851,7 @@ class ClassDashboard {
|
||||
// Recharger
|
||||
await this.fetchStats(trimester);
|
||||
|
||||
Notytex.utils.showToast('Données actualisées', 'success');
|
||||
ClassDashboard.utils.showToast('Données actualisées', 'success');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,8 +64,6 @@ class CouncilPreparation {
|
||||
// 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);
|
||||
@@ -539,7 +537,6 @@ class AutoSaveManager {
|
||||
|
||||
bindManualSaveButtons() {
|
||||
// Boutons supprimés de l'interface - auto-sauvegarde uniquement
|
||||
console.log('📝 Auto-sauvegarde activée - Pas de boutons manuels');
|
||||
}
|
||||
|
||||
setupCharacterCounter(textarea, studentId) {
|
||||
@@ -883,7 +880,6 @@ class FocusManager {
|
||||
// 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();
|
||||
}
|
||||
@@ -927,7 +923,6 @@ class FocusManager {
|
||||
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) {
|
||||
@@ -951,7 +946,6 @@ class FocusManager {
|
||||
// Sauvegarder l'état
|
||||
localStorage.setItem('council-focus-mode', 'true');
|
||||
|
||||
console.log('✨ Mode focus activé - Focus sur première appréciation');
|
||||
}
|
||||
|
||||
exitFocusMode() {
|
||||
@@ -1100,14 +1094,12 @@ class FocusManager {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1122,7 +1114,6 @@ class FocusManager {
|
||||
// É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);
|
||||
}
|
||||
});
|
||||
@@ -1132,14 +1123,12 @@ class FocusManager {
|
||||
}
|
||||
|
||||
// 2. Boutons supprimés - auto-sauvegarde uniquement
|
||||
console.log(`🔧 Mode focus: Auto-sauvegarde configurée pour élève ${studentId}`);
|
||||
|
||||
// 4. Gestion des barres de progression
|
||||
this.setupProgressBars(clonedStudent);
|
||||
}
|
||||
|
||||
ensureAllSectionsVisible(clonedStudent) {
|
||||
console.log('🔍 Vérification de la visibilité de toutes les sections en mode focus');
|
||||
|
||||
// S'assurer que les sections compétences/domaines sont visibles
|
||||
const competenceSection = clonedStudent.querySelector('.competence-domain-section');
|
||||
@@ -1147,7 +1136,6 @@ class FocusManager {
|
||||
competenceSection.style.display = 'block';
|
||||
competenceSection.style.minHeight = '200px';
|
||||
competenceSection.style.flexShrink = '0';
|
||||
console.log('✅ Section compétences/domaines visible');
|
||||
}
|
||||
|
||||
// S'assurer que les barres de progression sont configurées
|
||||
@@ -1157,20 +1145,17 @@ class FocusManager {
|
||||
});
|
||||
|
||||
// Section info supprimée - maintenant intégrée dans la zone d'appréciation
|
||||
console.log('✅ Informations intégrées 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';
|
||||
console.log('✅ Section résultats d\'évaluation visible');
|
||||
}
|
||||
|
||||
// S'assurer que la section progress-bars est visible
|
||||
const progressBarsSection = clonedStudent.querySelector('.progress-bars');
|
||||
if (progressBarsSection) {
|
||||
progressBarsSection.style.display = 'block';
|
||||
console.log('✅ Section barres de progression visible');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1568,7 +1553,6 @@ class FocusManager {
|
||||
if (!originalStudent || !focusStudent) return;
|
||||
|
||||
// Synchroniser les valeurs des barres si nécessaire
|
||||
console.log(`🔄 Synchronisation des barres de progression pour l'élève ${studentId}`);
|
||||
}
|
||||
|
||||
async saveFocusAppreciation(studentId, appreciation, immediate = false) {
|
||||
@@ -1591,7 +1575,6 @@ class FocusManager {
|
||||
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);
|
||||
|
||||
@@ -1696,7 +1679,6 @@ class FocusManager {
|
||||
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
|
||||
@@ -1738,7 +1720,6 @@ class FocusManager {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1746,7 +1727,6 @@ class FocusManager {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1788,10 +1768,6 @@ class FocusManager {
|
||||
// Debug des hauteurs
|
||||
const containerHeight = focusContainer.offsetHeight;
|
||||
const studentHeight = student.offsetHeight;
|
||||
console.log(`🎯 Mode focus optimisé:`);
|
||||
console.log(` Container height: ${containerHeight}px`);
|
||||
console.log(` Student height: ${studentHeight}px`);
|
||||
console.log(` Window height: ${window.innerHeight}px`);
|
||||
}
|
||||
|
||||
preserveJsonDataBeforeCloning(originalStudent) {
|
||||
|
||||
515
static/js/components/filter-manager.js
Normal file
515
static/js/components/filter-manager.js
Normal file
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* FILTER MANAGER - Système de filtrage unifié
|
||||
* Remplace les multiples implémentations de filtres
|
||||
*/
|
||||
|
||||
class FilterManager {
|
||||
constructor(core, config = {}) {
|
||||
this.core = core;
|
||||
this.config = {
|
||||
autoSubmit: true,
|
||||
debounceTime: 300,
|
||||
preserveState: true,
|
||||
updateUrl: true,
|
||||
...config
|
||||
};
|
||||
|
||||
this.filters = new Map();
|
||||
this.activeFilters = new Map();
|
||||
this.callbacks = new Set();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.core.components.set('filters', this);
|
||||
this.setupEventListeners();
|
||||
this.detectExistingFilters();
|
||||
|
||||
// Restaurer l'état depuis l'URL si configuré
|
||||
if (this.config.preserveState) {
|
||||
this.restoreFromUrl();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration des événements
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Délégation d'événements pour tous les filtres
|
||||
document.addEventListener('change', this.handleFilterChange.bind(this));
|
||||
document.addEventListener('click', this.handleFilterClick.bind(this));
|
||||
document.addEventListener('input', this.core.utils.debounce(
|
||||
this.handleFilterInput.bind(this),
|
||||
this.config.debounceTime
|
||||
));
|
||||
|
||||
// Écouter les resets
|
||||
document.addEventListener('click', this.handleResetClick.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecter les filtres existants dans le DOM
|
||||
*/
|
||||
detectExistingFilters() {
|
||||
// Sélecteurs data-filter (ancien système)
|
||||
this.core.utils.queryAll('[data-filter]').forEach(element => {
|
||||
const filterName = element.getAttribute('data-filter');
|
||||
this.registerFilter(filterName, {
|
||||
element,
|
||||
type: this.getFilterType(element),
|
||||
autoDetected: true
|
||||
});
|
||||
});
|
||||
|
||||
// Nouveaux sélecteurs filter-btn
|
||||
this.core.utils.queryAll('.filter-btn').forEach(element => {
|
||||
const filterName = element.getAttribute('data-filter');
|
||||
const filterValue = element.getAttribute('data-value');
|
||||
|
||||
if (filterName) {
|
||||
this.registerFilter(filterName, {
|
||||
element,
|
||||
type: 'button',
|
||||
value: filterValue,
|
||||
autoDetected: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Champs de recherche
|
||||
this.core.utils.queryAll('[data-search-filter]').forEach(element => {
|
||||
const filterName = element.getAttribute('data-search-filter');
|
||||
this.registerFilter(filterName, {
|
||||
element,
|
||||
type: 'search',
|
||||
autoDetected: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistrer un filtre
|
||||
*/
|
||||
registerFilter(name, options = {}) {
|
||||
const filter = {
|
||||
name,
|
||||
type: options.type || 'select',
|
||||
element: options.element,
|
||||
value: options.value || '',
|
||||
multiple: options.multiple || false,
|
||||
required: options.required || false,
|
||||
transform: options.transform || (value => value),
|
||||
validate: options.validate || (() => true),
|
||||
...options
|
||||
};
|
||||
|
||||
this.filters.set(name, filter);
|
||||
|
||||
// Marquer l'élément comme géré
|
||||
if (filter.element) {
|
||||
filter.element.setAttribute('data-filter-managed', 'true');
|
||||
}
|
||||
|
||||
this.core.emit('filter:registered', { name, filter });
|
||||
return filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Définir la valeur d'un filtre
|
||||
*/
|
||||
setFilter(name, value, options = {}) {
|
||||
const filter = this.filters.get(name);
|
||||
if (!filter) {
|
||||
console.warn(`Filter "${name}" not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (filter.validate && !filter.validate(value)) {
|
||||
console.warn(`Invalid value for filter "${name}": ${value}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Transformation
|
||||
const transformedValue = filter.transform(value);
|
||||
|
||||
// Mise à jour de l'état
|
||||
if (transformedValue === '' || transformedValue === null || transformedValue === undefined) {
|
||||
this.activeFilters.delete(name);
|
||||
} else {
|
||||
this.activeFilters.set(name, transformedValue);
|
||||
}
|
||||
|
||||
// Mise à jour de l'élément DOM
|
||||
this.updateFilterElement(filter, transformedValue);
|
||||
|
||||
// Mise à jour des tags visuels
|
||||
this.updateFilterTags();
|
||||
|
||||
// Auto-submit si configuré
|
||||
if (this.config.autoSubmit && !options.silent) {
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
this.core.emit('filter:changed', { name, value: transformedValue, filter });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir la valeur d'un filtre
|
||||
*/
|
||||
getFilter(name) {
|
||||
return this.activeFilters.get(name) || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir tous les filtres actifs
|
||||
*/
|
||||
getActiveFilters() {
|
||||
return Object.fromEntries(this.activeFilters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialiser tous les filtres
|
||||
*/
|
||||
reset(options = {}) {
|
||||
this.activeFilters.clear();
|
||||
|
||||
// Réinitialiser les éléments DOM
|
||||
this.filters.forEach(filter => {
|
||||
this.updateFilterElement(filter, '');
|
||||
});
|
||||
|
||||
this.updateFilterTags();
|
||||
|
||||
if (this.config.autoSubmit && !options.silent) {
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
this.core.emit('filters:reset');
|
||||
}
|
||||
|
||||
/**
|
||||
* Appliquer les filtres
|
||||
*/
|
||||
applyFilters() {
|
||||
const filters = this.getActiveFilters();
|
||||
|
||||
// Mise à jour de l'URL si configuré
|
||||
if (this.config.updateUrl) {
|
||||
this.updateUrl(filters);
|
||||
}
|
||||
|
||||
// Exécuter les callbacks
|
||||
this.callbacks.forEach(callback => {
|
||||
try {
|
||||
callback(filters);
|
||||
} catch (error) {
|
||||
console.error('Filter callback error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Émettre événement global
|
||||
this.core.emit('filters:applied', { filters });
|
||||
|
||||
// Par défaut, recharger la page avec les nouveaux paramètres
|
||||
if (this.callbacks.size === 0 && this.config.updateUrl) {
|
||||
window.location.href = window.location.href;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter un callback d'application des filtres
|
||||
*/
|
||||
onApply(callback) {
|
||||
this.callbacks.add(callback);
|
||||
|
||||
// Retourner fonction de désabonnement
|
||||
return () => {
|
||||
this.callbacks.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaurer les filtres depuis l'URL
|
||||
*/
|
||||
restoreFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
params.forEach((value, key) => {
|
||||
// Mapper les noms de paramètres aux noms de filtres
|
||||
const filterName = this.mapUrlParamToFilter(key);
|
||||
if (filterName && this.filters.has(filterName)) {
|
||||
this.setFilter(filterName, value, { silent: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour l'URL avec les filtres
|
||||
*/
|
||||
updateUrl(filters) {
|
||||
const url = new URL(window.location);
|
||||
const params = url.searchParams;
|
||||
|
||||
// Nettoyer les paramètres existants liés aux filtres
|
||||
this.filters.forEach((filter, name) => {
|
||||
const paramName = this.mapFilterToUrlParam(name);
|
||||
params.delete(paramName);
|
||||
});
|
||||
|
||||
// Ajouter les nouveaux paramètres
|
||||
Object.entries(filters).forEach(([name, value]) => {
|
||||
if (value !== '' && value !== null && value !== undefined) {
|
||||
const paramName = this.mapFilterToUrlParam(name);
|
||||
params.set(paramName, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Mise à jour sans rechargement
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire de changement pour selects et inputs
|
||||
*/
|
||||
handleFilterChange(event) {
|
||||
const element = event.target;
|
||||
if (!element.hasAttribute('data-filter') || element.hasAttribute('data-filter-managed')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filterName = element.getAttribute('data-filter');
|
||||
const value = this.getElementValue(element);
|
||||
|
||||
this.setFilter(filterName, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire de clic pour boutons de filtre
|
||||
*/
|
||||
handleFilterClick(event) {
|
||||
const button = event.target.closest('.filter-btn');
|
||||
if (!button) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const filterName = button.getAttribute('data-filter');
|
||||
const filterValue = button.getAttribute('data-value');
|
||||
|
||||
if (!filterName) return;
|
||||
|
||||
// Mise à jour des boutons visuels du même groupe
|
||||
this.updateFilterButtonGroup(filterName, filterValue);
|
||||
|
||||
// Application du filtre
|
||||
this.setFilter(filterName, filterValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire pour les champs de recherche
|
||||
*/
|
||||
handleFilterInput(event) {
|
||||
const element = event.target;
|
||||
if (!element.hasAttribute('data-search-filter')) return;
|
||||
|
||||
const filterName = element.getAttribute('data-search-filter');
|
||||
const value = element.value.trim();
|
||||
|
||||
this.setFilter(filterName, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire de reset
|
||||
*/
|
||||
handleResetClick(event) {
|
||||
const resetButton = event.target.closest('#reset-filters, [data-filter-reset]');
|
||||
if (!resetButton) return;
|
||||
|
||||
event.preventDefault();
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour l'élément DOM d'un filtre
|
||||
*/
|
||||
updateFilterElement(filter, value) {
|
||||
if (!filter.element) return;
|
||||
|
||||
switch (filter.type) {
|
||||
case 'select':
|
||||
filter.element.value = value;
|
||||
break;
|
||||
case 'input':
|
||||
case 'search':
|
||||
filter.element.value = value;
|
||||
break;
|
||||
case 'checkbox':
|
||||
filter.element.checked = !!value;
|
||||
break;
|
||||
case 'radio':
|
||||
filter.element.checked = filter.element.value === value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour un groupe de boutons de filtre
|
||||
*/
|
||||
updateFilterButtonGroup(filterName, activeValue) {
|
||||
const buttons = this.core.utils.queryAll(`[data-filter="${filterName}"].filter-btn`);
|
||||
|
||||
buttons.forEach(button => {
|
||||
const buttonValue = button.getAttribute('data-value');
|
||||
const isActive = buttonValue === activeValue;
|
||||
|
||||
// Mise à jour des classes CSS
|
||||
if (isActive) {
|
||||
button.classList.remove('bg-white', 'text-gray-600', 'border-gray-300');
|
||||
|
||||
// Appliquer le style approprié selon le type
|
||||
if (filterName === 'correction') {
|
||||
if (activeValue === 'not_started') {
|
||||
button.classList.add('bg-red-500', 'text-white', 'border-red-500');
|
||||
} else if (activeValue === 'incomplete') {
|
||||
button.classList.add('bg-orange-500', 'text-white', 'border-orange-500');
|
||||
} else if (activeValue === 'complete') {
|
||||
button.classList.add('bg-green-500', 'text-white', 'border-green-500');
|
||||
}
|
||||
} else {
|
||||
button.classList.add('bg-blue-600', 'text-white', 'border-blue-600');
|
||||
}
|
||||
} else {
|
||||
// Style inactif
|
||||
button.classList.remove('bg-blue-600', 'text-white', 'border-blue-600');
|
||||
button.classList.remove('bg-red-500', 'bg-orange-500', 'bg-green-500');
|
||||
button.classList.add('bg-white', 'text-gray-600', 'border-gray-300');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mettre à jour les tags de filtres actifs
|
||||
*/
|
||||
updateFilterTags() {
|
||||
const container = this.core.utils.query('#active-filter-tags');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
this.activeFilters.forEach((value, name) => {
|
||||
const tag = this.core.utils.createElement('span', {
|
||||
className: 'inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800'
|
||||
});
|
||||
|
||||
const text = this.core.utils.createElement('span');
|
||||
text.textContent = `${this.getFilterDisplayName(name)}: ${this.getValueDisplayName(name, value)}`;
|
||||
|
||||
const removeBtn = this.core.utils.createElement('button', {
|
||||
className: 'ml-1 hover:text-blue-600',
|
||||
onclick: () => this.setFilter(name, '')
|
||||
});
|
||||
removeBtn.innerHTML = '×';
|
||||
|
||||
tag.appendChild(text);
|
||||
tag.appendChild(removeBtn);
|
||||
container.appendChild(tag);
|
||||
});
|
||||
|
||||
// Afficher/masquer le bouton reset
|
||||
const resetButton = this.core.utils.query('#reset-filters');
|
||||
if (resetButton) {
|
||||
if (this.activeFilters.size > 0) {
|
||||
resetButton.classList.remove('hidden');
|
||||
} else {
|
||||
resetButton.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilitaires pour les types d'éléments
|
||||
*/
|
||||
getFilterType(element) {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const type = element.type;
|
||||
|
||||
if (tagName === 'select') return 'select';
|
||||
if (tagName === 'input') {
|
||||
if (type === 'checkbox') return 'checkbox';
|
||||
if (type === 'radio') return 'radio';
|
||||
return 'input';
|
||||
}
|
||||
if (element.classList.contains('filter-btn')) return 'button';
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
getElementValue(element) {
|
||||
const type = this.getFilterType(element);
|
||||
|
||||
switch (type) {
|
||||
case 'checkbox':
|
||||
return element.checked ? element.value || 'true' : '';
|
||||
case 'radio':
|
||||
return element.checked ? element.value : '';
|
||||
default:
|
||||
return element.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappage URL ↔ Filtres
|
||||
*/
|
||||
mapFilterToUrlParam(filterName) {
|
||||
const mapping = {
|
||||
'trimester': 'trimester',
|
||||
'class': 'class_id',
|
||||
'correction': 'correction',
|
||||
'search': 'q'
|
||||
};
|
||||
|
||||
return mapping[filterName] || filterName;
|
||||
}
|
||||
|
||||
mapUrlParamToFilter(paramName) {
|
||||
const reverseMapping = {
|
||||
'trimester': 'trimester',
|
||||
'class_id': 'class',
|
||||
'correction': 'correction',
|
||||
'q': 'search'
|
||||
};
|
||||
|
||||
return reverseMapping[paramName] || paramName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Noms d'affichage
|
||||
*/
|
||||
getFilterDisplayName(filterName) {
|
||||
const names = {
|
||||
'trimester': 'Trimestre',
|
||||
'class': 'Classe',
|
||||
'correction': 'État',
|
||||
'search': 'Recherche'
|
||||
};
|
||||
|
||||
return names[filterName] || filterName;
|
||||
}
|
||||
|
||||
getValueDisplayName(filterName, value) {
|
||||
const valueNames = {
|
||||
'correction': {
|
||||
'not_started': 'Non commencées',
|
||||
'incomplete': 'En cours',
|
||||
'complete': 'Terminées'
|
||||
},
|
||||
'trimester': {
|
||||
'1': 'T1', '2': 'T2', '3': 'T3'
|
||||
}
|
||||
};
|
||||
|
||||
return valueNames[filterName]?.[value] || value;
|
||||
}
|
||||
}
|
||||
|
||||
export default FilterManager;
|
||||
382
static/js/components/modal-manager.js
Normal file
382
static/js/components/modal-manager.js
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* MODAL MANAGER - Système de modales unifié
|
||||
* Fait partie du design system Notytex
|
||||
*/
|
||||
|
||||
class ModalManager {
|
||||
constructor(core, config = {}) {
|
||||
this.core = core;
|
||||
this.config = {
|
||||
backdrop: true,
|
||||
keyboard: true,
|
||||
focus: true,
|
||||
...config
|
||||
};
|
||||
|
||||
this.activeModals = new Set();
|
||||
this.modalStack = [];
|
||||
}
|
||||
|
||||
init() {
|
||||
this.core.components.set('modals', this);
|
||||
this.setupEventListeners();
|
||||
|
||||
// Auto-détecter les modales existantes
|
||||
this.core.utils.queryAll('.modal').forEach(modal => {
|
||||
this.registerModal(modal);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration des événements
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Gestion du clavier
|
||||
if (this.config.keyboard) {
|
||||
document.addEventListener('keydown', this.handleKeydown.bind(this));
|
||||
}
|
||||
|
||||
// Gestion des clics sur backdrop
|
||||
document.addEventListener('click', this.handleBackdropClick.bind(this));
|
||||
|
||||
// Détection automatique des triggers
|
||||
document.addEventListener('click', this.handleTriggerClick.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistrer une modale existante
|
||||
*/
|
||||
registerModal(modalElement) {
|
||||
const modalId = modalElement.id;
|
||||
if (!modalId) {
|
||||
console.warn('Modal sans ID détectée, ignorée');
|
||||
return;
|
||||
}
|
||||
|
||||
// Marquer comme gérée par le système
|
||||
modalElement.setAttribute('data-modal-managed', 'true');
|
||||
|
||||
// Configuration ARIA
|
||||
modalElement.setAttribute('role', 'dialog');
|
||||
modalElement.setAttribute('aria-modal', 'true');
|
||||
modalElement.setAttribute('aria-hidden', 'true');
|
||||
|
||||
return modalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer une modale programmatiquement
|
||||
*/
|
||||
create(options = {}) {
|
||||
const modalId = options.id || `modal-${Date.now()}`;
|
||||
|
||||
const modal = this.core.utils.createElement('div', {
|
||||
id: modalId,
|
||||
className: 'modal fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50',
|
||||
role: 'dialog',
|
||||
'aria-modal': 'true',
|
||||
'aria-hidden': 'true',
|
||||
'aria-labelledby': options.titleId || `${modalId}-title`
|
||||
});
|
||||
|
||||
const modalDialog = this.core.utils.createElement('div', {
|
||||
className: 'flex items-center justify-center min-h-screen p-4'
|
||||
});
|
||||
|
||||
const modalContent = this.core.utils.createElement('div', {
|
||||
className: `modal-content bg-white rounded-xl shadow-xl transform transition-all duration-300 scale-95 ${options.size === 'large' ? 'max-w-4xl' : options.size === 'small' ? 'max-w-md' : 'max-w-2xl'} w-full`
|
||||
});
|
||||
|
||||
// Header si fourni
|
||||
if (options.title || options.header) {
|
||||
const header = this.core.utils.createElement('div', {
|
||||
className: 'modal-header px-6 py-4 border-b border-gray-200 flex items-center justify-between'
|
||||
});
|
||||
|
||||
const title = this.core.utils.createElement('h3', {
|
||||
id: options.titleId || `${modalId}-title`,
|
||||
className: 'text-lg font-semibold text-gray-900'
|
||||
});
|
||||
|
||||
if (options.title) {
|
||||
title.textContent = options.title;
|
||||
} else if (options.header) {
|
||||
title.innerHTML = options.header;
|
||||
}
|
||||
|
||||
const closeButton = this.core.utils.createElement('button', {
|
||||
className: 'text-gray-400 hover:text-gray-600 transition-colors',
|
||||
onclick: () => this.close(modalId)
|
||||
});
|
||||
|
||||
closeButton.innerHTML = `
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(closeButton);
|
||||
modalContent.appendChild(header);
|
||||
}
|
||||
|
||||
// Body
|
||||
const body = this.core.utils.createElement('div', {
|
||||
className: 'modal-body p-6'
|
||||
});
|
||||
|
||||
if (options.content) {
|
||||
if (typeof options.content === 'string') {
|
||||
body.innerHTML = options.content;
|
||||
} else {
|
||||
body.appendChild(options.content);
|
||||
}
|
||||
}
|
||||
|
||||
modalContent.appendChild(body);
|
||||
|
||||
// Footer si fourni
|
||||
if (options.footer || options.actions) {
|
||||
const footer = this.core.utils.createElement('div', {
|
||||
className: 'modal-footer px-6 py-4 border-t border-gray-200 flex justify-end space-x-3'
|
||||
});
|
||||
|
||||
if (options.actions) {
|
||||
options.actions.forEach(action => {
|
||||
const button = this.core.utils.createElement('button', {
|
||||
className: `px-4 py-2 rounded-lg font-medium transition-colors ${this.getActionButtonClasses(action.type)}`,
|
||||
onclick: action.handler
|
||||
});
|
||||
button.textContent = action.text;
|
||||
footer.appendChild(button);
|
||||
});
|
||||
} else if (options.footer) {
|
||||
footer.innerHTML = options.footer;
|
||||
}
|
||||
|
||||
modalContent.appendChild(footer);
|
||||
}
|
||||
|
||||
modalDialog.appendChild(modalContent);
|
||||
modal.appendChild(modalDialog);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
this.registerModal(modal);
|
||||
return modalId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvrir une modale
|
||||
*/
|
||||
open(modalId, options = {}) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (!modal) {
|
||||
console.error(`Modal with ID "${modalId}" not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fermer autres modales si non-stackable
|
||||
if (!options.stackable && this.activeModals.size > 0) {
|
||||
this.closeAll();
|
||||
}
|
||||
|
||||
// Ajouter à la pile active
|
||||
this.activeModals.add(modalId);
|
||||
this.modalStack.push(modalId);
|
||||
|
||||
// Affichage
|
||||
modal.classList.remove('hidden');
|
||||
modal.classList.add('flex');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
|
||||
// Animation d'entrée
|
||||
const content = modal.querySelector('.modal-content');
|
||||
if (content) {
|
||||
requestAnimationFrame(() => {
|
||||
content.classList.remove('scale-95');
|
||||
content.classList.add('scale-100');
|
||||
});
|
||||
}
|
||||
|
||||
// Gestion du focus
|
||||
if (this.config.focus) {
|
||||
this.trapFocus(modal);
|
||||
}
|
||||
|
||||
// Bloquer le scroll du body
|
||||
document.body.classList.add('overflow-hidden');
|
||||
|
||||
// Émettre événement
|
||||
this.core.emit('modal:opened', { modalId, modal });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fermer une modale
|
||||
*/
|
||||
close(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (!modal || !this.activeModals.has(modalId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Animation de sortie
|
||||
const content = modal.querySelector('.modal-content');
|
||||
if (content) {
|
||||
content.classList.remove('scale-100');
|
||||
content.classList.add('scale-95');
|
||||
}
|
||||
|
||||
// Masquage après animation
|
||||
setTimeout(() => {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
}, 150);
|
||||
|
||||
// Retirer de la pile active
|
||||
this.activeModals.delete(modalId);
|
||||
const stackIndex = this.modalStack.indexOf(modalId);
|
||||
if (stackIndex > -1) {
|
||||
this.modalStack.splice(stackIndex, 1);
|
||||
}
|
||||
|
||||
// Restaurer le scroll si aucune modale active
|
||||
if (this.activeModals.size === 0) {
|
||||
document.body.classList.remove('overflow-hidden');
|
||||
}
|
||||
|
||||
// Émettre événement
|
||||
this.core.emit('modal:closed', { modalId, modal });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fermer toutes les modales
|
||||
*/
|
||||
closeAll() {
|
||||
Array.from(this.activeModals).forEach(modalId => {
|
||||
this.close(modalId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Piégeage du focus dans la modale
|
||||
*/
|
||||
trapFocus(modal) {
|
||||
const focusableElements = modal.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
// Focus initial
|
||||
if (firstElement) {
|
||||
firstElement.focus();
|
||||
}
|
||||
|
||||
// Gestionnaire de tabulation
|
||||
const handleTab = (event) => {
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
if (event.shiftKey) {
|
||||
// Shift + Tab
|
||||
if (document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab
|
||||
if (document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
modal.addEventListener('keydown', handleTab);
|
||||
|
||||
// Nettoyer à la fermeture
|
||||
const cleanup = () => {
|
||||
modal.removeEventListener('keydown', handleTab);
|
||||
this.core.off('modal:closed', cleanup);
|
||||
};
|
||||
this.core.on('modal:closed', cleanup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire de clics sur backdrop
|
||||
*/
|
||||
handleBackdropClick(event) {
|
||||
if (!this.config.backdrop) return;
|
||||
|
||||
const modal = event.target.closest('.modal');
|
||||
if (!modal || !this.activeModals.has(modal.id)) return;
|
||||
|
||||
// Vérifier si le clic est sur le backdrop (pas le contenu)
|
||||
if (event.target === modal || event.target.classList.contains('modal-backdrop')) {
|
||||
this.close(modal.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire de touches clavier
|
||||
*/
|
||||
handleKeydown(event) {
|
||||
if (event.key === 'Escape' && this.modalStack.length > 0) {
|
||||
const topModalId = this.modalStack[this.modalStack.length - 1];
|
||||
this.close(topModalId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire de clics sur triggers
|
||||
*/
|
||||
handleTriggerClick(event) {
|
||||
const trigger = event.target.closest('[data-modal-target]');
|
||||
if (!trigger) return;
|
||||
|
||||
event.preventDefault();
|
||||
const targetId = trigger.getAttribute('data-modal-target');
|
||||
const options = {
|
||||
stackable: trigger.hasAttribute('data-modal-stackable')
|
||||
};
|
||||
|
||||
this.open(targetId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Classes CSS pour les boutons d'action
|
||||
*/
|
||||
getActionButtonClasses(type) {
|
||||
const classes = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
|
||||
success: 'bg-green-600 text-white hover:bg-green-700',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||
warning: 'bg-orange-600 text-white hover:bg-orange-700'
|
||||
};
|
||||
|
||||
return classes[type] || classes.secondary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilitaires publics
|
||||
*/
|
||||
isOpen(modalId) {
|
||||
return this.activeModals.has(modalId);
|
||||
}
|
||||
|
||||
getActiveModals() {
|
||||
return Array.from(this.activeModals);
|
||||
}
|
||||
|
||||
hasActiveModals() {
|
||||
return this.activeModals.size > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default ModalManager;
|
||||
297
static/js/components/notification-system.js
Normal file
297
static/js/components/notification-system.js
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* NOTIFICATION SYSTEM - Composant unifié pour toasts et alertes
|
||||
* Fait partie du design system Notytex
|
||||
*/
|
||||
|
||||
class NotificationSystem {
|
||||
constructor(core, config = {}) {
|
||||
this.core = core;
|
||||
this.config = {
|
||||
position: 'top-right',
|
||||
duration: 3000,
|
||||
maxVisible: 5,
|
||||
animation: 'slide',
|
||||
...config
|
||||
};
|
||||
|
||||
this.notifications = new Map();
|
||||
this.container = null;
|
||||
this.nextId = 1;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createContainer();
|
||||
this.core.components.set('notifications', this);
|
||||
|
||||
// Enregistrer dans le core pour accès global
|
||||
this.core.on('notification:show', (event) => {
|
||||
this.show(event.detail);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer le conteneur pour les notifications
|
||||
*/
|
||||
createContainer() {
|
||||
if (this.container) return;
|
||||
|
||||
const positions = {
|
||||
'top-right': 'fixed top-4 right-4 z-50',
|
||||
'top-left': 'fixed top-4 left-4 z-50',
|
||||
'bottom-right': 'fixed bottom-4 right-4 z-50',
|
||||
'bottom-left': 'fixed bottom-4 left-4 z-50',
|
||||
'top-center': 'fixed top-4 left-1/2 transform -translate-x-1/2 z-50',
|
||||
'bottom-center': 'fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50'
|
||||
};
|
||||
|
||||
this.container = this.core.utils.createElement('div', {
|
||||
id: 'notytex-notifications',
|
||||
className: `${positions[this.config.position]} space-y-2 pointer-events-none`
|
||||
});
|
||||
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Afficher une notification
|
||||
*/
|
||||
show(options = {}) {
|
||||
const notification = {
|
||||
id: this.nextId++,
|
||||
type: options.type || 'info',
|
||||
title: options.title || '',
|
||||
message: options.message || '',
|
||||
duration: options.duration || this.config.duration,
|
||||
actions: options.actions || [],
|
||||
persistent: options.persistent || false,
|
||||
...options
|
||||
};
|
||||
|
||||
// Limiter le nombre de notifications visibles
|
||||
if (this.notifications.size >= this.config.maxVisible) {
|
||||
const oldestId = Array.from(this.notifications.keys())[0];
|
||||
this.hide(oldestId);
|
||||
}
|
||||
|
||||
this.notifications.set(notification.id, notification);
|
||||
this.render(notification);
|
||||
|
||||
// Auto-masquage si non persistant
|
||||
if (!notification.persistent && notification.duration > 0) {
|
||||
setTimeout(() => {
|
||||
this.hide(notification.id);
|
||||
}, notification.duration);
|
||||
}
|
||||
|
||||
return notification.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Afficher types spécifiques (méthodes de convenance)
|
||||
*/
|
||||
success(message, title = 'Succès', options = {}) {
|
||||
return this.show({ type: 'success', message, title, ...options });
|
||||
}
|
||||
|
||||
error(message, title = 'Erreur', options = {}) {
|
||||
return this.show({ type: 'error', message, title, ...options });
|
||||
}
|
||||
|
||||
warning(message, title = 'Attention', options = {}) {
|
||||
return this.show({ type: 'warning', message, title, ...options });
|
||||
}
|
||||
|
||||
info(message, title = 'Information', options = {}) {
|
||||
return this.show({ type: 'info', message, title, ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Masquer une notification
|
||||
*/
|
||||
hide(notificationId) {
|
||||
const notification = this.notifications.get(notificationId);
|
||||
if (!notification) return;
|
||||
|
||||
const element = document.getElementById(`notification-${notificationId}`);
|
||||
if (element) {
|
||||
// Animation de sortie
|
||||
element.style.transition = 'all 300ms cubic-bezier(0.4, 0, 0.2, 1)';
|
||||
element.style.transform = 'translateX(100%)';
|
||||
element.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
if (element.parentNode) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
this.notifications.delete(notificationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Masquer toutes les notifications
|
||||
*/
|
||||
hideAll() {
|
||||
Array.from(this.notifications.keys()).forEach(id => {
|
||||
this.hide(id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendu d'une notification
|
||||
*/
|
||||
render(notification) {
|
||||
const typeConfig = this.getTypeConfig(notification.type);
|
||||
|
||||
const element = this.core.utils.createElement('div', {
|
||||
id: `notification-${notification.id}`,
|
||||
className: `pointer-events-auto max-w-sm w-full ${typeConfig.bgColor} border ${typeConfig.borderColor} shadow-lg rounded-lg overflow-hidden transform transition-all duration-300 translate-x-full opacity-0`,
|
||||
role: 'alert',
|
||||
'aria-live': 'polite'
|
||||
});
|
||||
|
||||
const content = this.core.utils.createElement('div', {
|
||||
className: 'p-4'
|
||||
});
|
||||
|
||||
const header = this.core.utils.createElement('div', {
|
||||
className: 'flex items-start'
|
||||
});
|
||||
|
||||
// Icône
|
||||
const iconContainer = this.core.utils.createElement('div', {
|
||||
className: 'flex-shrink-0'
|
||||
});
|
||||
iconContainer.innerHTML = typeConfig.icon;
|
||||
|
||||
// Contenu
|
||||
const textContent = this.core.utils.createElement('div', {
|
||||
className: 'ml-3 w-0 flex-1'
|
||||
});
|
||||
|
||||
if (notification.title) {
|
||||
const title = this.core.utils.createElement('p', {
|
||||
className: `text-sm font-medium ${typeConfig.textColor}`
|
||||
});
|
||||
title.textContent = notification.title;
|
||||
textContent.appendChild(title);
|
||||
}
|
||||
|
||||
if (notification.message) {
|
||||
const message = this.core.utils.createElement('p', {
|
||||
className: `text-sm ${typeConfig.textColor} ${notification.title ? 'mt-1' : ''}`
|
||||
});
|
||||
message.textContent = notification.message;
|
||||
textContent.appendChild(message);
|
||||
}
|
||||
|
||||
// Actions
|
||||
if (notification.actions && notification.actions.length > 0) {
|
||||
const actionsContainer = this.core.utils.createElement('div', {
|
||||
className: 'mt-3 flex space-x-2'
|
||||
});
|
||||
|
||||
notification.actions.forEach(action => {
|
||||
const button = this.core.utils.createElement('button', {
|
||||
className: `text-sm font-medium ${typeConfig.actionColor} hover:${typeConfig.actionHoverColor} transition-colors`,
|
||||
onclick: action.handler
|
||||
});
|
||||
button.textContent = action.text;
|
||||
actionsContainer.appendChild(button);
|
||||
});
|
||||
|
||||
textContent.appendChild(actionsContainer);
|
||||
}
|
||||
|
||||
// Bouton de fermeture
|
||||
const closeButton = this.core.utils.createElement('div', {
|
||||
className: 'ml-4 flex-shrink-0 flex'
|
||||
});
|
||||
|
||||
const closeBtn = this.core.utils.createElement('button', {
|
||||
className: `rounded-md inline-flex ${typeConfig.textColor} hover:${typeConfig.closeHoverColor} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`,
|
||||
onclick: () => this.hide(notification.id)
|
||||
});
|
||||
|
||||
closeBtn.innerHTML = `
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
closeButton.appendChild(closeBtn);
|
||||
|
||||
// Assemblage
|
||||
header.appendChild(iconContainer);
|
||||
header.appendChild(textContent);
|
||||
header.appendChild(closeButton);
|
||||
content.appendChild(header);
|
||||
element.appendChild(content);
|
||||
|
||||
this.container.appendChild(element);
|
||||
|
||||
// Animation d'entrée
|
||||
requestAnimationFrame(() => {
|
||||
element.classList.remove('translate-x-full', 'opacity-0');
|
||||
});
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration des types de notification
|
||||
*/
|
||||
getTypeConfig(type) {
|
||||
const configs = {
|
||||
success: {
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
textColor: 'text-green-800',
|
||||
actionColor: 'text-green-600',
|
||||
actionHoverColor: 'text-green-500',
|
||||
closeHoverColor: 'text-green-500',
|
||||
icon: `<svg class="w-5 h-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>`
|
||||
},
|
||||
error: {
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
textColor: 'text-red-800',
|
||||
actionColor: 'text-red-600',
|
||||
actionHoverColor: 'text-red-500',
|
||||
closeHoverColor: 'text-red-500',
|
||||
icon: `<svg class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>`
|
||||
},
|
||||
warning: {
|
||||
bgColor: 'bg-orange-50',
|
||||
borderColor: 'border-orange-200',
|
||||
textColor: 'text-orange-800',
|
||||
actionColor: 'text-orange-600',
|
||||
actionHoverColor: 'text-orange-500',
|
||||
closeHoverColor: 'text-orange-500',
|
||||
icon: `<svg class="w-5 h-5 text-orange-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>`
|
||||
},
|
||||
info: {
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
textColor: 'text-blue-800',
|
||||
actionColor: 'text-blue-600',
|
||||
actionHoverColor: 'text-blue-500',
|
||||
closeHoverColor: 'text-blue-500',
|
||||
icon: `<svg class="w-5 h-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>`
|
||||
}
|
||||
};
|
||||
|
||||
return configs[type] || configs.info;
|
||||
}
|
||||
}
|
||||
|
||||
export default NotificationSystem;
|
||||
496
static/js/notytex-core.js
Normal file
496
static/js/notytex-core.js
Normal file
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* NOTYTEX CORE - Système JavaScript Unifié
|
||||
* Architecture moderne ES6 pour l'application Notytex
|
||||
* Version 2.0 - Refactoring complet pour uniformisation
|
||||
*/
|
||||
|
||||
class NotytexCore {
|
||||
constructor() {
|
||||
this.modules = new Map();
|
||||
this.components = new Map();
|
||||
this.observers = new Map();
|
||||
this.config = this.getDefaultConfig();
|
||||
this.state = this.createReactiveState();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration par défaut unifiée
|
||||
*/
|
||||
getDefaultConfig() {
|
||||
return {
|
||||
// Design tokens JavaScript
|
||||
designTokens: {
|
||||
transitions: {
|
||||
fast: 150,
|
||||
normal: 300,
|
||||
slow: 500,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
},
|
||||
breakpoints: {
|
||||
mobile: '(max-width: 767px)',
|
||||
tablet: '(min-width: 768px) and (max-width: 1023px)',
|
||||
desktop: '(min-width: 1024px)'
|
||||
},
|
||||
colors: {
|
||||
primary: '#3b82f6',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444'
|
||||
}
|
||||
},
|
||||
|
||||
// Configuration des modules
|
||||
modules: {
|
||||
animations: { enabled: true, performanceMode: false },
|
||||
filters: { autoSubmit: true, debounceTime: 300 },
|
||||
notifications: { position: 'top-right', duration: 3000 },
|
||||
modals: { backdrop: true, keyboard: true }
|
||||
},
|
||||
|
||||
// Performance
|
||||
performance: {
|
||||
lazyLoading: true,
|
||||
bundleOptimization: true,
|
||||
cacheStrategy: 'aggressive'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer un état réactif avec Proxy
|
||||
*/
|
||||
createReactiveState() {
|
||||
const state = {};
|
||||
const self = this;
|
||||
|
||||
return new Proxy(state, {
|
||||
set(target, property, value) {
|
||||
const oldValue = target[property];
|
||||
target[property] = value;
|
||||
|
||||
// Notifier les observateurs (vérifier que observers existe)
|
||||
if (self.observers && self.observers.has(property)) {
|
||||
self.observers.get(property).forEach(callback => {
|
||||
callback(value, oldValue, property);
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
get(target, property) {
|
||||
return target[property];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisation du core
|
||||
*/
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.detectEnvironment();
|
||||
this.loadCoreModules();
|
||||
this.initializeAnimations();
|
||||
|
||||
// Déclenchement de l'événement de prêt
|
||||
this.emit('core:ready');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistrer un module
|
||||
*/
|
||||
registerModule(name, moduleClass, config = {}) {
|
||||
try {
|
||||
const moduleInstance = new moduleClass(this, config);
|
||||
this.modules.set(name, moduleInstance);
|
||||
|
||||
// Auto-init si le module le support
|
||||
if (typeof moduleInstance.init === 'function') {
|
||||
moduleInstance.init();
|
||||
}
|
||||
|
||||
this.emit('module:registered', { name, module: moduleInstance });
|
||||
return moduleInstance;
|
||||
} catch (error) {
|
||||
console.error(`Failed to register module ${name}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtenir un module
|
||||
*/
|
||||
getModule(name) {
|
||||
return this.modules.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement paresseux d'un module
|
||||
*/
|
||||
async loadModule(name, path) {
|
||||
if (this.modules.has(name)) {
|
||||
return this.modules.get(name);
|
||||
}
|
||||
|
||||
try {
|
||||
const moduleFile = await import(path);
|
||||
const ModuleClass = moduleFile.default || moduleFile[name];
|
||||
|
||||
return this.registerModule(name, ModuleClass);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load module ${name} from ${path}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire d'état réactif
|
||||
*/
|
||||
setState(updates) {
|
||||
Object.assign(this.state, updates);
|
||||
}
|
||||
|
||||
getState(key) {
|
||||
return key ? this.state[key] : this.state;
|
||||
}
|
||||
|
||||
subscribe(key, callback) {
|
||||
if (!this.observers.has(key)) {
|
||||
this.observers.set(key, new Set());
|
||||
}
|
||||
this.observers.get(key).add(callback);
|
||||
|
||||
// Retourner fonction de désabonnement
|
||||
return () => {
|
||||
this.observers.get(key).delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Système d'événements unifié
|
||||
*/
|
||||
emit(eventName, data = null) {
|
||||
const event = new CustomEvent(eventName, {
|
||||
detail: data,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
on(eventName, callback) {
|
||||
document.addEventListener(eventName, callback);
|
||||
return () => document.removeEventListener(eventName, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilitaires unifiés
|
||||
*/
|
||||
utils = {
|
||||
// Debounce optimisé
|
||||
debounce: (func, wait, immediate = false) => {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
if (!immediate) func(...args);
|
||||
};
|
||||
const callNow = immediate && !timeout;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
if (callNow) func(...args);
|
||||
};
|
||||
},
|
||||
|
||||
// Throttle pour performance
|
||||
throttle: (func, limit) => {
|
||||
let inThrottle;
|
||||
return function(...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
// Création d'éléments sécurisée
|
||||
createElement: (tag, attributes = {}, children = []) => {
|
||||
const element = document.createElement(tag);
|
||||
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
if (key === 'className') {
|
||||
element.className = value;
|
||||
} else if (key === 'innerHTML') {
|
||||
element.innerHTML = value;
|
||||
} else {
|
||||
element.setAttribute(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
children.forEach(child => {
|
||||
if (typeof child === 'string') {
|
||||
element.appendChild(document.createTextNode(child));
|
||||
} else {
|
||||
element.appendChild(child);
|
||||
}
|
||||
});
|
||||
|
||||
return element;
|
||||
},
|
||||
|
||||
// Sélecteur sécurisé
|
||||
query: (selector, context = document) => {
|
||||
try {
|
||||
return context.querySelector(selector);
|
||||
} catch (error) {
|
||||
console.warn(`Invalid selector: ${selector}`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
queryAll: (selector, context = document) => {
|
||||
try {
|
||||
return Array.from(context.querySelectorAll(selector));
|
||||
} catch (error) {
|
||||
console.warn(`Invalid selector: ${selector}`, error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// Copie dans le presse-papiers
|
||||
copyToClipboard: async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
NotytexCore.instance.components.get('notifications')?.show({
|
||||
type: 'success',
|
||||
message: 'Copié dans le presse-papiers'
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Clipboard error:', error);
|
||||
NotytexCore.instance.components.get('notifications')?.show({
|
||||
type: 'error',
|
||||
message: 'Erreur lors de la copie'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Détection de l'environnement
|
||||
*/
|
||||
detectEnvironment() {
|
||||
this.state.environment = {
|
||||
isMobile: window.matchMedia(this.config.designTokens.breakpoints.mobile).matches,
|
||||
isTablet: window.matchMedia(this.config.designTokens.breakpoints.tablet).matches,
|
||||
isDesktop: window.matchMedia(this.config.designTokens.breakpoints.desktop).matches,
|
||||
hasTouch: 'ontouchstart' in window,
|
||||
supportsClipboard: !!navigator.clipboard,
|
||||
supportsIntersectionObserver: 'IntersectionObserver' in window,
|
||||
prefersReducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
};
|
||||
|
||||
// Observer les changements de breakpoints
|
||||
Object.entries(this.config.designTokens.breakpoints).forEach(([name, query]) => {
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
mediaQuery.addListener(() => {
|
||||
this.state.environment[`is${name.charAt(0).toUpperCase() + name.slice(1)}`] = mediaQuery.matches;
|
||||
this.emit('breakpoint:change', { name, matches: mediaQuery.matches });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement des modules essentiels
|
||||
*/
|
||||
loadCoreModules() {
|
||||
// Ces modules seront chargés automatiquement
|
||||
const coreModules = [
|
||||
'NotificationSystem',
|
||||
'ModalManager',
|
||||
'AnimationController',
|
||||
'FilterManager'
|
||||
];
|
||||
|
||||
// Pour l'instant, on enregistre des placeholders
|
||||
// qui seront remplacés par les vrais modules
|
||||
coreModules.forEach(moduleName => {
|
||||
this.modules.set(moduleName.toLowerCase(), {
|
||||
name: moduleName,
|
||||
loaded: false,
|
||||
placeholder: true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisation des animations
|
||||
*/
|
||||
initializeAnimations() {
|
||||
if (!this.config.modules.animations.enabled) return;
|
||||
if (this.state.environment.prefersReducedMotion) return;
|
||||
|
||||
// Observer d'intersection pour animations au scroll
|
||||
if (this.state.environment.supportsIntersectionObserver) {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('in-view');
|
||||
// Désactiver l'observation une fois animé
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
});
|
||||
|
||||
// Observer tous les éléments avec animation
|
||||
this.utils.queryAll('.animate-on-scroll').forEach(el => {
|
||||
observer.observe(el);
|
||||
});
|
||||
|
||||
this.state.scrollObserver = observer;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration des événements globaux
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Gestion des clics globaux
|
||||
document.addEventListener('click', this.handleGlobalClick.bind(this));
|
||||
|
||||
// Gestion des touches globales
|
||||
document.addEventListener('keydown', this.handleGlobalKeydown.bind(this));
|
||||
|
||||
// Gestion des liens de confirmation
|
||||
document.addEventListener('click', this.handleConfirmationLinks.bind(this));
|
||||
|
||||
// Gestion du resize window
|
||||
window.addEventListener('resize',
|
||||
this.utils.throttle(() => {
|
||||
this.emit('window:resize', {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
});
|
||||
}, 250)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire de clics globaux
|
||||
*/
|
||||
handleGlobalClick(event) {
|
||||
// Fermeture des dropdowns
|
||||
const openDropdowns = this.utils.queryAll('.dropdown-menu.show');
|
||||
openDropdowns.forEach(dropdown => {
|
||||
if (!dropdown.contains(event.target)) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Gestion des boutons de copie
|
||||
const copyButton = event.target.closest('[data-copy]');
|
||||
if (copyButton) {
|
||||
event.preventDefault();
|
||||
const textToCopy = copyButton.dataset.copy;
|
||||
this.utils.copyToClipboard(textToCopy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire de touches globales
|
||||
*/
|
||||
handleGlobalKeydown(event) {
|
||||
// Fermeture des modales avec Escape
|
||||
if (event.key === 'Escape') {
|
||||
const openModal = this.utils.query('.modal:not(.hidden)');
|
||||
if (openModal && this.components.has('modals')) {
|
||||
this.components.get('modals').close(openModal.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestionnaire des liens de confirmation
|
||||
*/
|
||||
handleConfirmationLinks(event) {
|
||||
const confirmLink = event.target.closest('[data-confirm]');
|
||||
if (!confirmLink) return;
|
||||
|
||||
event.preventDefault();
|
||||
const message = confirmLink.dataset.confirm;
|
||||
const title = confirmLink.dataset.confirmTitle || 'Confirmation';
|
||||
|
||||
this.showConfirmation(message, title).then(confirmed => {
|
||||
if (confirmed) {
|
||||
if (confirmLink.tagName === 'A') {
|
||||
window.location.href = confirmLink.href;
|
||||
} else if (confirmLink.tagName === 'BUTTON') {
|
||||
confirmLink.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Système de confirmation unifié
|
||||
*/
|
||||
showConfirmation(message, title = 'Confirmation') {
|
||||
return new Promise((resolve) => {
|
||||
// Si SweetAlert2 est disponible
|
||||
if (typeof Swal !== 'undefined') {
|
||||
Swal.fire({
|
||||
title,
|
||||
text: message,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Confirmer',
|
||||
cancelButtonText: 'Annuler',
|
||||
confirmButtonColor: this.config.designTokens.colors.danger,
|
||||
cancelButtonColor: '#6b7280'
|
||||
}).then((result) => {
|
||||
resolve(result.isConfirmed);
|
||||
});
|
||||
} else {
|
||||
// Fallback natif
|
||||
resolve(confirm(`${title}\n\n${message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* API publique unifiée
|
||||
*/
|
||||
static getInstance() {
|
||||
if (!NotytexCore.instance) {
|
||||
NotytexCore.instance = new NotytexCore();
|
||||
}
|
||||
return NotytexCore.instance;
|
||||
}
|
||||
}
|
||||
|
||||
// Instance globale singleton
|
||||
NotytexCore.instance = null;
|
||||
|
||||
// Export pour utilisation
|
||||
window.NotytexCore = NotytexCore;
|
||||
window.Notytex = NotytexCore.getInstance();
|
||||
|
||||
// Auto-initialisation
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!NotytexCore.instance) {
|
||||
window.Notytex = NotytexCore.getInstance();
|
||||
}
|
||||
});
|
||||
|
||||
export default NotytexCore;
|
||||
469
static/js/notytex-unified.js
Normal file
469
static/js/notytex-unified.js
Normal file
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* NOTYTEX UNIFIED - Point d'entrée unifié
|
||||
* Charge et initialise tous les composants modernes
|
||||
* Remplace l'ancien notytex.js
|
||||
*/
|
||||
|
||||
import NotytexCore from './notytex-core.js';
|
||||
import NotificationSystem from './components/notification-system.js';
|
||||
import ModalManager from './components/modal-manager.js';
|
||||
import FilterManager from './components/filter-manager.js';
|
||||
|
||||
/**
|
||||
* Gestionnaire d'initialisation unifié
|
||||
*/
|
||||
class NotytexApp {
|
||||
constructor() {
|
||||
this.core = null;
|
||||
this.initialized = false;
|
||||
this.loadingPromise = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisation asynchrone
|
||||
*/
|
||||
async init() {
|
||||
if (this.initialized) return this.core;
|
||||
if (this.loadingPromise) return this.loadingPromise;
|
||||
|
||||
this.loadingPromise = this.performInit();
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
async performInit() {
|
||||
try {
|
||||
|
||||
// 1. Initialiser le core
|
||||
this.core = NotytexCore.getInstance();
|
||||
|
||||
// 2. Enregistrer les composants essentiels
|
||||
await this.registerCoreComponents();
|
||||
|
||||
// 3. Initialiser les modules page-spécifiques
|
||||
await this.initializePageModules();
|
||||
|
||||
// 4. Configuration des intégrations legacy
|
||||
this.setupLegacyIntegration();
|
||||
|
||||
// 5. Optimisations performance
|
||||
this.setupPerformanceOptimizations();
|
||||
|
||||
this.initialized = true;
|
||||
|
||||
// Émission de l'événement ready
|
||||
this.core.emit('notytex:ready');
|
||||
|
||||
return this.core;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize Notytex:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistrement des composants essentiels
|
||||
*/
|
||||
async registerCoreComponents() {
|
||||
const components = [
|
||||
['notifications', NotificationSystem],
|
||||
['modals', ModalManager],
|
||||
['filters', FilterManager]
|
||||
];
|
||||
|
||||
for (const [name, ComponentClass] of components) {
|
||||
try {
|
||||
this.core.registerModule(name, ComponentClass);
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to register ${name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisation des modules spécifiques aux pages
|
||||
*/
|
||||
async initializePageModules() {
|
||||
const currentPage = this.detectCurrentPage();
|
||||
|
||||
|
||||
// Modules spécifiques par page
|
||||
const pageModules = {
|
||||
'assessments': ['ClassDashboard'],
|
||||
'classes': ['ClassManagement'],
|
||||
'grading': ['GradingInterface'],
|
||||
'council': ['CouncilPreparation']
|
||||
};
|
||||
|
||||
if (pageModules[currentPage]) {
|
||||
for (const moduleName of pageModules[currentPage]) {
|
||||
await this.loadPageModule(moduleName, currentPage);
|
||||
}
|
||||
}
|
||||
|
||||
// Modules universels (présents sur toutes les pages)
|
||||
await this.initializeUniversalModules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Détection de la page actuelle
|
||||
*/
|
||||
detectCurrentPage() {
|
||||
const path = window.location.pathname;
|
||||
const body = document.body;
|
||||
|
||||
// Détection par URL
|
||||
if (path.includes('/assessments')) return 'assessments';
|
||||
if (path.includes('/classes')) return 'classes';
|
||||
if (path.includes('/grading')) return 'grading';
|
||||
if (path.includes('/council')) return 'council';
|
||||
if (path.includes('/students')) return 'students';
|
||||
if (path.includes('/config')) return 'config';
|
||||
|
||||
// Détection par data attributes
|
||||
if (body.hasAttribute('data-page')) {
|
||||
return body.getAttribute('data-page');
|
||||
}
|
||||
|
||||
// Détection par title ou meta
|
||||
const title = document.title.toLowerCase();
|
||||
if (title.includes('évaluation')) return 'assessments';
|
||||
if (title.includes('classe')) return 'classes';
|
||||
if (title.includes('note')) return 'grading';
|
||||
|
||||
return 'general';
|
||||
}
|
||||
|
||||
/**
|
||||
* Chargement des modules de page
|
||||
*/
|
||||
async loadPageModule(moduleName, pageName) {
|
||||
try {
|
||||
// Mapping des noms de modules aux fichiers
|
||||
const moduleFiles = {
|
||||
'ClassDashboard': './ClassDashboard.js',
|
||||
'CouncilPreparation': './CouncilPreparation.js'
|
||||
};
|
||||
|
||||
const filePath = moduleFiles[moduleName];
|
||||
if (!filePath) {
|
||||
console.warn(`Module file not found for ${moduleName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier si le fichier existe (fallback)
|
||||
try {
|
||||
await this.core.loadModule(moduleName, filePath);
|
||||
} catch (loadError) {
|
||||
console.warn(`Module ${moduleName} not available, creating placeholder`);
|
||||
this.createModulePlaceholder(moduleName);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to load page module ${moduleName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisation des modules universels
|
||||
*/
|
||||
async initializeUniversalModules() {
|
||||
// Animation au scroll pour tous les éléments
|
||||
this.initializeScrollAnimations();
|
||||
|
||||
// Gestion des filtres sur toutes les pages avec filtres
|
||||
this.initializePageFilters();
|
||||
|
||||
// Tooltips et popovers
|
||||
this.initializeTooltips();
|
||||
|
||||
// Raccourcis clavier
|
||||
this.initializeKeyboardShortcuts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Animations au scroll
|
||||
*/
|
||||
initializeScrollAnimations() {
|
||||
const elements = this.core.utils.queryAll('[data-animate]');
|
||||
if (elements.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const animationType = entry.target.dataset.animate || 'fade-in-up';
|
||||
entry.target.classList.add(animationType);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
});
|
||||
|
||||
elements.forEach(el => observer.observe(el));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtres de page
|
||||
*/
|
||||
initializePageFilters() {
|
||||
const filterElements = this.core.utils.queryAll('[data-filter], .filter-btn');
|
||||
if (filterElements.length === 0) return;
|
||||
|
||||
const filterManager = this.core.getModule('filters');
|
||||
if (!filterManager) return;
|
||||
|
||||
// Configuration spécifique pour les pages de liste
|
||||
filterManager.onApply((filters) => {
|
||||
this.handleFilterApplication(filters);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Application des filtres avec feedback visuel
|
||||
*/
|
||||
handleFilterApplication(filters) {
|
||||
// Afficher un indicateur de chargement
|
||||
this.showFilterLoading();
|
||||
|
||||
// Mettre à jour le compteur si présent
|
||||
this.updateFilterCounts(filters);
|
||||
|
||||
// Par défaut, recharger après un court délai pour l'UX
|
||||
setTimeout(() => {
|
||||
window.location.href = window.location.href;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicateur de chargement pour filtres
|
||||
*/
|
||||
showFilterLoading() {
|
||||
const container = this.core.utils.query('#filtered-count');
|
||||
if (container) {
|
||||
container.innerHTML = '<span class="animate-pulse">...</span>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mise à jour des compteurs de filtres
|
||||
*/
|
||||
updateFilterCounts(filters) {
|
||||
// Cette méthode peut être étendue pour faire des appels AJAX
|
||||
// pour obtenir les compteurs en temps réel
|
||||
}
|
||||
|
||||
/**
|
||||
* Tooltips simples
|
||||
*/
|
||||
initializeTooltips() {
|
||||
const tooltipElements = this.core.utils.queryAll('[title], [data-tooltip]');
|
||||
|
||||
tooltipElements.forEach(element => {
|
||||
const tooltipText = element.getAttribute('data-tooltip') || element.getAttribute('title');
|
||||
if (!tooltipText) return;
|
||||
|
||||
// Retirer le title natif pour éviter les doublons
|
||||
element.removeAttribute('title');
|
||||
|
||||
let tooltip = null;
|
||||
|
||||
const showTooltip = (event) => {
|
||||
tooltip = this.core.utils.createElement('div', {
|
||||
className: 'absolute z-50 px-2 py-1 text-xs text-white bg-gray-900 rounded shadow-lg pointer-events-none',
|
||||
style: 'top: -9999px; left: -9999px;'
|
||||
});
|
||||
tooltip.textContent = tooltipText;
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
|
||||
const top = rect.top - tooltipRect.height - 5;
|
||||
const left = rect.left + (rect.width - tooltipRect.width) / 2;
|
||||
|
||||
tooltip.style.top = `${top}px`;
|
||||
tooltip.style.left = `${left}px`;
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
if (tooltip) {
|
||||
tooltip.remove();
|
||||
tooltip = null;
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('mouseenter', showTooltip);
|
||||
element.addEventListener('mouseleave', hideTooltip);
|
||||
element.addEventListener('focus', showTooltip);
|
||||
element.addEventListener('blur', hideTooltip);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Raccourcis clavier
|
||||
*/
|
||||
initializeKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
// Ctrl/Cmd + K pour recherche rapide
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
const searchInput = this.core.utils.query('input[type="search"], [data-search-filter]');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Échap pour fermer les overlays
|
||||
if (event.key === 'Escape') {
|
||||
// Fermer les dropdowns
|
||||
this.core.utils.queryAll('.dropdown-menu.show').forEach(dropdown => {
|
||||
dropdown.classList.remove('show');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Intégration avec le code legacy
|
||||
*/
|
||||
setupLegacyIntegration() {
|
||||
// Maintenir la compatibilité avec l'ancien namespace
|
||||
window.Notytex = {
|
||||
// API de transition
|
||||
utils: this.core.utils,
|
||||
state: this.core.state,
|
||||
emit: this.core.emit.bind(this.core),
|
||||
on: this.core.on.bind(this.core),
|
||||
|
||||
// Composants
|
||||
notifications: this.core.getModule('notifications'),
|
||||
modals: this.core.getModule('modals'),
|
||||
filters: this.core.getModule('filters'),
|
||||
|
||||
// Méthodes legacy
|
||||
showToast: (message, type = 'info') => {
|
||||
this.core.getModule('notifications')?.show({ message, type });
|
||||
},
|
||||
|
||||
showModal: (modalId) => {
|
||||
this.core.getModule('modals')?.open(modalId);
|
||||
},
|
||||
|
||||
closeModal: (modalId) => {
|
||||
this.core.getModule('modals')?.close(modalId);
|
||||
}
|
||||
};
|
||||
|
||||
// Maintenir les anciennes fonctions globales si utilisées
|
||||
if (!window.showToast) {
|
||||
window.showToast = window.Notytex.showToast;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimisations de performance
|
||||
*/
|
||||
setupPerformanceOptimizations() {
|
||||
// Lazy loading des images
|
||||
this.setupLazyLoading();
|
||||
|
||||
// Preload des liens importants
|
||||
this.setupLinkPreloading();
|
||||
|
||||
// Monitoring des performances
|
||||
this.setupPerformanceMonitoring();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy loading des images
|
||||
*/
|
||||
setupLazyLoading() {
|
||||
if ('IntersectionObserver' in window) {
|
||||
const imageObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
const src = img.getAttribute('data-src');
|
||||
if (src) {
|
||||
img.src = src;
|
||||
img.removeAttribute('data-src');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.core.utils.queryAll('img[data-src]').forEach(img => {
|
||||
imageObserver.observe(img);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload des liens
|
||||
*/
|
||||
setupLinkPreloading() {
|
||||
// Preload au hover pour les liens internes
|
||||
const links = this.core.utils.queryAll('a[href^="/"], a[href^="./"]');
|
||||
const preloadedUrls = new Set();
|
||||
|
||||
links.forEach(link => {
|
||||
link.addEventListener('mouseenter', () => {
|
||||
const href = link.href;
|
||||
if (!preloadedUrls.has(href)) {
|
||||
const preloadLink = document.createElement('link');
|
||||
preloadLink.rel = 'prefetch';
|
||||
preloadLink.href = href;
|
||||
document.head.appendChild(preloadLink);
|
||||
preloadedUrls.add(href);
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitoring des performances
|
||||
*/
|
||||
setupPerformanceMonitoring() {
|
||||
if ('PerformanceObserver' in window) {
|
||||
// Observer les métriques de performance
|
||||
const perfObserver = new PerformanceObserver((list) => {
|
||||
list.getEntries().forEach(entry => {
|
||||
if (entry.entryType === 'measure') {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
perfObserver.observe({ entryTypes: ['measure', 'navigation'] });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Créer un placeholder pour un module manquant
|
||||
*/
|
||||
createModulePlaceholder(moduleName) {
|
||||
const placeholder = {
|
||||
name: moduleName,
|
||||
placeholder: true,
|
||||
init: () => {},
|
||||
destroy: () => {}
|
||||
};
|
||||
|
||||
this.core.modules.set(moduleName.toLowerCase(), placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
// Instance globale
|
||||
const notytexApp = new NotytexApp();
|
||||
|
||||
// Auto-initialisation
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => notytexApp.init());
|
||||
} else {
|
||||
notytexApp.init();
|
||||
}
|
||||
|
||||
// Export pour utilisation
|
||||
export default notytexApp;
|
||||
@@ -22,7 +22,6 @@ class SimpleFilters {
|
||||
// Initialiser l'état visuel
|
||||
this.updateUI();
|
||||
|
||||
console.log('SimpleFilters initialized');
|
||||
}
|
||||
|
||||
attachEvents() {
|
||||
|
||||
@@ -119,8 +119,15 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- JavaScript Core -->
|
||||
<script src="{{ url_for('static', filename='js/notytex.js') }}"></script>
|
||||
<!-- JavaScript Unifié Moderne -->
|
||||
<script type="module" src="{{ url_for('static', filename='js/notytex-unified.js') }}"></script>
|
||||
|
||||
<!-- Fallback pour anciens navigateurs -->
|
||||
<script nomodule>
|
||||
console.warn('Navigateur non supporté - Veuillez utiliser un navigateur moderne');
|
||||
// Fallback vers l'ancien système si nécessaire
|
||||
document.head.insertAdjacentHTML('beforeend', '<script src="{{ url_for("static", filename="js/notytex.js") }}"><\/script>');
|
||||
</script>
|
||||
|
||||
<!-- JavaScript spécifique aux pages -->
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
@@ -447,6 +447,10 @@
|
||||
|
||||
{% block head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<!-- Injection des données statistiques dans le JavaScript -->
|
||||
<script>
|
||||
window.initialStatsData = {{ stats_data|tojson|safe }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/ClassDashboard.js') }}"></script>
|
||||
<style>
|
||||
/* Fix pour éviter le clipping des hover effects sur cette page */
|
||||
|
||||
@@ -1,156 +1,134 @@
|
||||
{% extends "base.html" %}
|
||||
{% from 'components/common/macros.html' import hero_section %}
|
||||
{% from 'components/common/macros.html' import page_layout, content_section, empty_state %}
|
||||
{% from 'components/class/class_card.html' import class_card %}
|
||||
|
||||
{% block title %}Classes - Gestion Scolaire{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
{# Hero Section avec composant réutilisable #}
|
||||
{% set meta_info = [
|
||||
{
|
||||
'icon': '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
||||
'text': classes|length ~ ' classes actives'
|
||||
},
|
||||
{
|
||||
'icon': '<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clip-rule="evenodd"/></svg>',
|
||||
'text': 'Année scolaire 2024-2025'
|
||||
}
|
||||
] %}
|
||||
{% set primary_action = {
|
||||
'url': url_for('classes.new'),
|
||||
{% call page_layout(
|
||||
"Mes Classes 🏫",
|
||||
"Gérez et organisez toutes vos classes",
|
||||
[],
|
||||
[{
|
||||
'url': url_for('classes.new') if url_for else '#',
|
||||
'text': 'Nouvelle classe',
|
||||
'icon': '<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>'
|
||||
} %}
|
||||
{{ hero_section(
|
||||
title="Mes Classes 🏫",
|
||||
subtitle="Gérez et organisez toutes vos classes",
|
||||
meta_info=meta_info,
|
||||
primary_action=primary_action,
|
||||
gradient_class="from-blue-600 to-green-600"
|
||||
) }}
|
||||
'icon': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>',
|
||||
'variant': 'primary'
|
||||
}]
|
||||
) %}
|
||||
|
||||
{% if classes %}
|
||||
<!-- Grille de classes avec composants -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for class in classes %}
|
||||
{{ class_card(class) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- État vide moderne -->
|
||||
<div class="bg-white rounded-xl shadow-lg p-12 text-center">
|
||||
<div class="w-24 h-24 bg-gradient-to-br from-blue-100 to-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<svg class="w-12 h-12 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{% call content_section("Classes disponibles", classes|length ~ " classes actives") %}
|
||||
{% if classes %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for class in classes %}
|
||||
{{ class_card(class) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">Aucune classe créée</h3>
|
||||
<p class="text-gray-600 mb-6 max-w-md mx-auto">
|
||||
Commencez votre gestion scolaire en créant votre première classe.
|
||||
Vous pourrez ensuite y ajouter des élèves et créer des évaluations.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<a href="{{ url_for('classes.new') }}" class="inline-flex items-center bg-gradient-to-r from-blue-500 to-green-500 hover:from-blue-600 hover:to-green-600 text-white px-6 py-3 rounded-xl transition-all duration-300 font-semibold shadow-lg hover:shadow-xl transform hover:scale-105">
|
||||
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Créer ma première classe
|
||||
</a>
|
||||
|
||||
<div class="text-sm text-gray-500">
|
||||
<p>💡 <strong>Astuce :</strong> Une classe peut contenir plusieurs élèves et être utilisée pour de nombreuses évaluations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ empty_state(
|
||||
"Aucune classe créée",
|
||||
"Commencez votre gestion scolaire en créant votre première classe. Vous pourrez ensuite y ajouter des élèves et créer des évaluations.",
|
||||
'<svg class="w-12 h-12 text-blue-600" fill="currentColor" viewBox="0 0 20 20"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
|
||||
{
|
||||
'url': url_for('classes.new') if url_for else '#',
|
||||
'text': 'Créer ma première classe',
|
||||
'icon': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/></svg>',
|
||||
'variant': 'primary'
|
||||
},
|
||||
{
|
||||
'text': 'Une classe peut contenir plusieurs élèves et être utilisée pour de nombreuses évaluations'
|
||||
}
|
||||
) }}
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
<!-- Modal de confirmation de suppression -->
|
||||
<div id="deleteModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl max-w-md w-full">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-12 h-12 text-red-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<!-- Modal de confirmation de suppression -->
|
||||
<div id="deleteModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 hidden z-50">
|
||||
<div class="flex items-center justify-center min-h-screen p-4">
|
||||
<div class="bg-white rounded-xl shadow-xl max-w-md w-full">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-12 h-12 text-red-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">Supprimer la classe</h3>
|
||||
<p class="mt-1 text-sm text-gray-600" id="deleteMessage">
|
||||
Êtes-vous sûr de vouloir supprimer cette classe ?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">Supprimer la classe</h3>
|
||||
<p class="mt-1 text-sm text-gray-600" id="deleteMessage">
|
||||
Êtes-vous sûr de vouloir supprimer cette classe ?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button onclick="closeDeleteModal()" class="bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
<form id="deleteForm" method="POST" class="inline">
|
||||
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
Supprimer définitivement
|
||||
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button onclick="closeDeleteModal()" class="bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
Annuler
|
||||
</button>
|
||||
</form>
|
||||
<form id="deleteForm" method="POST" class="inline">
|
||||
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
Supprimer définitivement
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function confirmDeleteClass(classId, className, studentsCount, assessmentsCount) {
|
||||
const modal = document.getElementById('deleteModal');
|
||||
const message = document.getElementById('deleteMessage');
|
||||
const form = document.getElementById('deleteForm');
|
||||
|
||||
// Configuration du message selon le contenu de la classe
|
||||
if (studentsCount > 0 || assessmentsCount > 0) {
|
||||
message.innerHTML = `
|
||||
<strong>Impossible de supprimer la classe "${className}".</strong><br>
|
||||
Elle contient <strong>${studentsCount} élève(s)</strong> et <strong>${assessmentsCount} évaluation(s)</strong>.<br>
|
||||
Supprimez d'abord ces éléments pour pouvoir supprimer la classe.
|
||||
`;
|
||||
// Masquer le bouton de suppression et changer le titre
|
||||
document.querySelector('#deleteForm button').style.display = 'none';
|
||||
document.querySelector('#deleteModal h3').textContent = 'Suppression impossible';
|
||||
} else {
|
||||
message.innerHTML = `
|
||||
Êtes-vous sûr de vouloir supprimer la classe <strong>"${className}"</strong> ?<br>
|
||||
<span class="text-red-600">Cette action est irréversible.</span>
|
||||
`;
|
||||
// Réafficher le bouton de suppression et restaurer le titre
|
||||
document.querySelector('#deleteForm button').style.display = 'inline';
|
||||
document.querySelector('#deleteModal h3').textContent = 'Supprimer la classe';
|
||||
}
|
||||
|
||||
// Configuration de l'action du formulaire
|
||||
form.action = `/classes/${classId}/delete`;
|
||||
|
||||
// Afficher le modal
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Fermeture au clic sur le fond
|
||||
document.getElementById('deleteModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Fermeture avec la touche Échap
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endcall %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function confirmDeleteClass(classId, className, studentsCount, assessmentsCount) {
|
||||
const modal = document.getElementById('deleteModal');
|
||||
const message = document.getElementById('deleteMessage');
|
||||
const form = document.getElementById('deleteForm');
|
||||
|
||||
// Configuration du message selon le contenu de la classe
|
||||
if (studentsCount > 0 || assessmentsCount > 0) {
|
||||
message.innerHTML = `
|
||||
<strong>Impossible de supprimer la classe "${className}".</strong><br>
|
||||
Elle contient <strong>${studentsCount} élève(s)</strong> et <strong>${assessmentsCount} évaluation(s)</strong>.<br>
|
||||
Supprimez d'abord ces éléments pour pouvoir supprimer la classe.
|
||||
`;
|
||||
// Masquer le bouton de suppression et changer le titre
|
||||
document.querySelector('#deleteForm button').style.display = 'none';
|
||||
document.querySelector('#deleteModal h3').textContent = 'Suppression impossible';
|
||||
} else {
|
||||
message.innerHTML = `
|
||||
Êtes-vous sûr de vouloir supprimer la classe <strong>"${className}"</strong> ?<br>
|
||||
<span class="text-red-600">Cette action est irréversible.</span>
|
||||
`;
|
||||
// Réafficher le bouton de suppression et restaurer le titre
|
||||
document.querySelector('#deleteForm button').style.display = 'inline';
|
||||
document.querySelector('#deleteModal h3').textContent = 'Supprimer la classe';
|
||||
}
|
||||
|
||||
// Configuration de l'action du formulaire
|
||||
form.action = `/classes/${classId}/delete`;
|
||||
|
||||
// Afficher le modal
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Fermeture au clic sur le fond
|
||||
document.getElementById('deleteModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Fermeture avec la touche Échap
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -212,6 +212,125 @@
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro pour page layout standardisé avec hero section #}
|
||||
{% macro page_layout(title, subtitle=None, breadcrumbs=[], actions=[]) %}
|
||||
{% if breadcrumbs %}
|
||||
<nav class="flex mb-6" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2 text-sm">
|
||||
{% for crumb in breadcrumbs %}
|
||||
<li class="flex items-center">
|
||||
{% if not loop.last %}
|
||||
<a href="{{ crumb.url }}" class="text-gray-500 hover:text-gray-700 transition-colors">
|
||||
{{ crumb.text }}
|
||||
</a>
|
||||
<svg class="w-4 h-4 mx-2 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<span class="text-gray-900 font-medium">{{ crumb.text }}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<!-- Hero Section avec gradient -->
|
||||
<div class="bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl p-8 shadow-lg mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-4xl font-bold mb-2">{{ title }}</h1>
|
||||
{% if subtitle %}
|
||||
<p class="text-xl opacity-90 mb-1">{{ subtitle }}</p>
|
||||
{% endif %}
|
||||
<p class="text-sm opacity-75">{{ moment().format('dddd D MMMM YYYY') if moment else 'Lundi 16 août 2025' }}</p>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
{% if actions %}
|
||||
<div class="flex items-center space-x-3">
|
||||
{% for action in actions %}
|
||||
<a href="{{ action.url }}"
|
||||
class="bg-white/20 hover:bg-white/30 text-white px-6 py-3 rounded-xl transition-all duration-300 font-semibold shadow-lg hover:shadow-xl transform hover:scale-105 flex items-center">
|
||||
{% if action.icon %}{{ action.icon|safe }}{% endif %}
|
||||
{{ action.text }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="w-24 h-24 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ caller() }}
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro pour section de contenu standardisée #}
|
||||
{% macro content_section(title=None, description=None, actions=[], classes="") %}
|
||||
<div class="bg-white rounded-xl shadow-lg overflow-hidden {{ classes }}">
|
||||
{% if title %}
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900">{{ title }}</h2>
|
||||
{% if description %}
|
||||
<p class="text-sm text-gray-600 mt-1">{{ description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if actions %}
|
||||
<div class="flex items-center space-x-2">
|
||||
{% for action in actions %}
|
||||
<a href="{{ action.url }}"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
||||
{% if action.icon %}{{ action.icon|safe }}{% endif %}
|
||||
{{ action.text }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="p-6">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro pour état vide unifié #}
|
||||
{% macro empty_state(title, description, icon, action=None, secondary_action=None) %}
|
||||
<div class="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div class="p-6 text-center py-12">
|
||||
<div class="w-24 h-24 bg-gradient-to-br from-gray-100 to-gray-200 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
{{ icon|safe }}
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">{{ title }}</h3>
|
||||
<p class="text-base text-gray-600 mb-6 max-w-md mx-auto">{{ description }}</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
{% if action %}
|
||||
<a href="{{ action.url }}"
|
||||
class="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold">
|
||||
{% if action.icon %}{{ action.icon|safe }}{% endif %}
|
||||
{{ action.text }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if secondary_action %}
|
||||
<div class="text-sm text-gray-600">
|
||||
<p><strong>💡 Astuce :</strong> {{ secondary_action.text }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro pour filtres simplifiés et discrets #}
|
||||
{% macro simple_filter_section(filters_config, current_values={}, total_items=0, filtered_items=0) %}
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-6 border border-gray-200">
|
||||
@@ -292,4 +411,130 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro pour tableau standardisé #}
|
||||
{% macro data_table(headers, rows, actions_column=False, zebra=True) %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
{% for header in headers %}
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ header }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
{% if actions_column %}
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for row in rows %}
|
||||
<tr class="{% if zebra and loop.index % 2 == 0 %}bg-gray-50{% endif %} hover:bg-blue-50 transition-colors">
|
||||
{% for cell in row.cells %}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ cell|safe }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
{% if actions_column and row.actions %}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex justify-end space-x-2">
|
||||
{% for action in row.actions %}
|
||||
<a href="{{ action.url }}"
|
||||
class="text-{{ action.color|default('blue') }}-600 hover:text-{{ action.color|default('blue') }}-900 transition-colors">
|
||||
{{ action.text }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro pour liste d'éléments uniformisée #}
|
||||
{% macro item_list(items, show_meta=True, show_actions=True) %}
|
||||
<div class="space-tight">
|
||||
{% for item in items %}
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 hover:bg-blue-50 rounded-lg transition-colors group">
|
||||
<a href="{{ item.url }}" class="flex items-center space-x-4 flex-1 min-w-0">
|
||||
{% if item.avatar %}
|
||||
<div class="w-10 h-10 {{ item.avatar.classes|default('bg-gradient-to-br from-blue-500 to-purple-600') }} rounded-full flex items-center justify-center text-white font-bold text-sm">
|
||||
{{ item.avatar.content|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-sm font-semibold text-gray-900 group-hover:text-blue-700 transition-colors truncate">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
{% if show_meta and item.meta %}
|
||||
<div class="flex items-center text-xs text-gray-500 space-x-2 mt-1">
|
||||
{% for meta in item.meta %}
|
||||
<span class="flex items-center">
|
||||
{% if meta.icon %}{{ meta.icon|safe }}{% endif %}
|
||||
{{ meta.text }}
|
||||
</span>
|
||||
{% if not loop.last %}<span>•</span>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{% if show_actions and item.indicators %}
|
||||
<div class="flex items-center space-x-3 flex-shrink-0">
|
||||
{% for indicator in item.indicators %}
|
||||
{{ indicator|safe }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# Macro pour notifications/alertes uniformisées #}
|
||||
{% macro alert(type, title, message, dismissible=True, icon=True) %}
|
||||
{% set alert_classes = {
|
||||
'info': 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
'success': 'bg-green-50 border-green-200 text-green-800',
|
||||
'warning': 'bg-orange-50 border-orange-200 text-orange-800',
|
||||
'error': 'bg-red-50 border-red-200 text-red-800'
|
||||
} %}
|
||||
|
||||
{% set icons = {
|
||||
'info': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/></svg>',
|
||||
'success': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>',
|
||||
'warning': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/></svg>',
|
||||
'error': '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/></svg>'
|
||||
} %}
|
||||
|
||||
<div class="border rounded-lg p-4 {{ alert_classes[type] }} {% if dismissible %}relative{% endif %}" role="alert">
|
||||
<div class="flex items-start">
|
||||
{% if icon %}
|
||||
<div class="flex-shrink-0 mr-3 mt-0.5">
|
||||
{{ icons[type]|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-medium">{{ title }}</h3>
|
||||
<p class="text-sm mt-1">{{ message }}</p>
|
||||
</div>
|
||||
{% if dismissible %}
|
||||
<button type="button" class="flex-shrink-0 ml-3 hover:opacity-75 transition-opacity"
|
||||
onclick="this.parentElement.remove()">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -1,58 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% from 'components/common/macros.html' import page_layout, content_section, alert, stat_card, action_card %}
|
||||
|
||||
{% block title %}Accueil - Gestion Scolaire{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<!-- Notifications contextuelles -->
|
||||
{% call page_layout("Bonjour ! 👋", "Prêt à gérer vos évaluations aujourd'hui ?") %}
|
||||
<!-- Notifications contextuelles avec composant unifié -->
|
||||
{% if total_assessments == 0 %}
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-blue-400 mt-0.5 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-blue-800">Bienvenue dans Notytex ! 🎉</h3>
|
||||
<p class="text-sm text-blue-700 mt-1">
|
||||
Commencez par créer votre première évaluation.
|
||||
<a href="{{ url_for('assessments.new') }}" class="underline hover:no-underline font-medium">Créer maintenant</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ alert('info', 'Bienvenue dans Notytex ! 🎉', 'Commencez par créer votre première évaluation. <a href="' + url_for('assessments.new') + '" class="underline hover:no-underline font-medium">Créer maintenant</a>', false) }}
|
||||
{% elif recent_assessments and recent_assessments|length >= 3 %}
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-green-400 mt-0.5 mr-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-green-800">Système bien utilisé ! 📈</h3>
|
||||
<p class="text-sm text-green-700 mt-1">
|
||||
Vous avez {{ total_assessments }} évaluations créées. Pensez à exporter vos données régulièrement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ alert('success', 'Système bien utilisé ! 📈', 'Vous avez ' + total_assessments|string + ' évaluations créées. Pensez à exporter vos données régulièrement.', false) }}
|
||||
{% endif %}
|
||||
|
||||
<!-- Hero Section avec accueil personnalisé -->
|
||||
<div class="bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-xl p-8 shadow-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold mb-2">Bonjour ! 👋</h1>
|
||||
<p class="text-xl opacity-90 mb-1">Prêt à gérer vos évaluations aujourd'hui ?</p>
|
||||
<p class="text-sm opacity-75">{{ moment().format('dddd D MMMM YYYY') if moment else 'Lundi 4 août 2025' }}</p>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<div class="w-24 h-24 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-12 h-12" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hero Section déjà gérée par page_layout -->
|
||||
|
||||
<!-- Statistiques enrichies et cliquables -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
@@ -341,4 +302,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user