Compare commits
6 Commits
ba25dd19db
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b8aae00ea7 | |||
| 6cca179346 | |||
| bb15933e69 | |||
| a0ab7224e1 | |||
| b1b7d12a9f | |||
| a30dd4af19 |
17
.env.example
17
.env.example
@@ -1,17 +0,0 @@
|
||||
# Configuration de développement
|
||||
SECRET_KEY=your-secret-key-here-min-32-chars-dev-example-key-2025
|
||||
DATABASE_URL=sqlite:///school_management.db
|
||||
FLASK_ENV=development
|
||||
LOG_LEVEL=INFO
|
||||
DEBUG=true
|
||||
|
||||
# Configuration de production (décommentez et ajustez pour la production)
|
||||
# SECRET_KEY=your-production-secret-key-min-32-chars-prod
|
||||
# DATABASE_URL=postgresql://user:pass@localhost/notytex_prod
|
||||
# FLASK_ENV=production
|
||||
# LOG_LEVEL=WARNING
|
||||
# DEBUG=false
|
||||
|
||||
# Configuration optionnelle
|
||||
# DB_ECHO=false
|
||||
# WTF_CSRF_TIME_LIMIT=3600
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -82,7 +82,3 @@ htmlcov/
|
||||
# Docker data
|
||||
data/
|
||||
!data/.gitkeep
|
||||
|
||||
# Flask legacy
|
||||
instance/
|
||||
.webassets-cache
|
||||
923
CLAUDE.md
923
CLAUDE.md
@@ -1,628 +1,397 @@
|
||||
# 📚 Notytex - Système de Gestion Scolaire
|
||||
# Notytex v2 - Système de Gestion Scolaire
|
||||
|
||||
**Notytex** est une application web Flask moderne conçue pour la gestion complète des évaluations scolaires. Elle permet aux enseignants de créer, organiser et noter les évaluations de leurs élèves avec une interface intuitive et des fonctionnalités avancées.
|
||||
**Notytex** est une application web moderne pour la gestion complète des évaluations scolaires. Version 2.0 entièrement réécrite avec **FastAPI** (backend) et **Vue.js 3** (frontend).
|
||||
|
||||
## 🎯 **Objectif Principal**
|
||||
## Objectif Principal
|
||||
|
||||
Simplifier et digitaliser le processus d'évaluation scolaire, de la création des contrôles à la saisie des notes, en offrant une structure hiérarchique flexible et deux modes de notation.
|
||||
Simplifier et digitaliser le processus d'évaluation scolaire, de la création des contrôles à la saisie des notes, en offrant une interface moderne, réactive et une API REST documentée.
|
||||
|
||||
## 🏗️ **Architecture Technique **
|
||||
---
|
||||
|
||||
**Framework :** Flask (Python) avec architecture modulaire découplée
|
||||
**Base de données :** SQLite avec SQLAlchemy ORM + Repository Pattern
|
||||
**Frontend :** Templates Jinja2 + TailwindCSS + JavaScript + Chart.js
|
||||
**Tests :** Pytest avec couverture complète (100 tests ✅)
|
||||
**Configuration :** Variables d'environnement externalisées (.env)
|
||||
**Logging :** Structuré JSON avec corrélation des requêtes
|
||||
**Sécurité :** Configuration sécurisée + gestion d'erreurs centralisée
|
||||
## Architecture Technique
|
||||
|
||||
## 📊 **Modèle de Données Hiérarchique**
|
||||
| Couche | Technologie |
|
||||
|--------|-------------|
|
||||
| **Backend** | FastAPI 0.115+ (Python 3.11-3.13) |
|
||||
| **ORM** | SQLAlchemy 2.0.36+ avec aiosqlite (async) |
|
||||
| **Validation** | Pydantic 2.10+ / pydantic-settings |
|
||||
| **Serveur** | Uvicorn 0.32+ (ASGI) |
|
||||
| **Frontend** | Vue.js 3.5+ (Composition API) + Vite 6.0+ |
|
||||
| **State** | Pinia 2.2+ |
|
||||
| **CSS** | TailwindCSS 3.4+ |
|
||||
| **Graphiques** | Chart.js 4.4+ (vue-chartjs) |
|
||||
| **HTTP client** | Axios 1.7+ |
|
||||
| **Base de données** | SQLite (dev/prod), PostgreSQL possible |
|
||||
| **Déploiement** | Docker / Podman avec Nginx |
|
||||
| **Tests** | pytest + pytest-asyncio (99/99 tests) |
|
||||
| **Qualité** | Black, Ruff, ESLint |
|
||||
|
||||
---
|
||||
|
||||
## Modèle de Données (12 tables)
|
||||
|
||||
### Entités principales (7)
|
||||
|
||||
```
|
||||
ClassGroup (6ème A, 5ème B...)
|
||||
↓ StudentEnrollment (inscription temporelle : arrivée/départ)
|
||||
Student (élèves)
|
||||
↓
|
||||
Students (Élèves de la classe)
|
||||
Assessment (évaluation, rattachée à un trimestre 1/2/3)
|
||||
↓
|
||||
Assessment (Contrôle de mathématiques, Trimestre 1...)
|
||||
Exercise (exercices ordonnés)
|
||||
↓
|
||||
Exercise (Exercice 1, Exercice 2...)
|
||||
GradingElement (question/compétence, type "notes" ou "score", domaine optionnel)
|
||||
↓
|
||||
GradingElement (Question a, b, c...)
|
||||
↓
|
||||
Grade (Note attribuée à chaque élève)
|
||||
Grade (note individuelle par élève : valeur string + commentaire)
|
||||
```
|
||||
|
||||
## ⭐ **Fonctionnalités Clés**
|
||||
- **CouncilAppreciation** : appréciation de conseil de classe (élève + classe + trimestre, statut draft/finalized)
|
||||
|
||||
### **Gestion des Évaluations**
|
||||
### Tables de configuration (5)
|
||||
|
||||
- Création d'évaluations complètes avec exercices multiples
|
||||
- **Organisation par trimestre** : Chaque évaluation doit être assignée à un trimestre (1, 2 ou 3)
|
||||
- Structure hiérarchique : Assessment → Exercise → GradingElement
|
||||
- Interface unifiée pour créer évaluation + exercices + barème en une fois
|
||||
- Modification et suppression avec gestion des cascades
|
||||
- **AppConfig** : clé-valeur (ex: `context.school_year`, `grading.special_values`)
|
||||
- **CompetenceScaleValue** : échelle de notation (0=Non acquis → 3=Expert, couleurs, `.`, `d`, `a`)
|
||||
- **Competence** : compétences pédagogiques (Calculer, Raisonner...) avec couleur et icône
|
||||
- **Domain** : domaines/tags pour les éléments de notation
|
||||
|
||||
### **Système de Notation Unifié **
|
||||
|
||||
**2 Types de Notation Fixes :**
|
||||
|
||||
1. **`notes`** : Valeurs numériques décimales (ex: 2.5/4, 18/20, 15.5/20)
|
||||
2. **`score`** : Échelle fixe de 0 à 3 pour l'évaluation par compétences
|
||||
|
||||
**Valeurs Spéciales Configurables :**
|
||||
|
||||
- **`.`** = Pas de réponse (traité comme 0 dans les calculs)
|
||||
- **`d`** = Dispensé (ne compte pas dans la note finale)
|
||||
- **Autres valeurs** : Entièrement configurables via l'interface d'administration
|
||||
|
||||
**Configuration Centralisée :**
|
||||
|
||||
- **Signification des scores** : 0=Non acquis, 1=En cours, 2=Acquis, 3=Expert (modifiable)
|
||||
- **Couleurs associées** : Chaque niveau peut avoir sa couleur personnalisée
|
||||
- **Règles de calcul** : Logique unifiée pour tous les types de notation
|
||||
- **Interface d'administration** : Gestion complète des paramètres de notation
|
||||
|
||||
### **Filtrage Avancé des Évaluations**
|
||||
|
||||
**Filtres Dynamiques Disponibles :**
|
||||
|
||||
- **Trimestre** : Filtrage par trimestre (1, 2, 3) pour organiser par période scolaire
|
||||
- **Classe** : Filtrage par groupe de classe pour se concentrer sur une classe spécifique
|
||||
- **Statut de Correction** : **NOUVEAU** - Filtre essentiel pour la gestion des corrections :
|
||||
- **Non terminées** : Évaluations partiellement corrigées ou non commencées (idéal pour mode midyear)
|
||||
- **Terminées** : Évaluations 100% corrigées
|
||||
- **Non commencées** : Évaluations sans aucune note saisie
|
||||
- **Tri** : Organisation par date (récent/ancien), titre alphabétique, ou classe
|
||||
|
||||
**JavaScript Dynamique :**
|
||||
|
||||
- **Filtrage temps réel** : Les filtres s'appliquent automatiquement au changement
|
||||
- **Persistence des filtres** : État maintenu dans l'URL pour navigation intuitive
|
||||
- **Interface responsive** : Adaptée aux appareils mobiles et desktop
|
||||
|
||||
### **Interface Utilisateur & UX Moderne **
|
||||
|
||||
- **Dashboard avec statistiques en temps réel** : Cartes cliquables avec animations et gradients
|
||||
- **Pages hero modernisées** : Sections d'accueil avec gradients colorés et informations contextuelles
|
||||
- **Navigation intuitive** : Actions principales mises en avant avec boutons colorés et icônes
|
||||
- **Templates responsive** avec TailwindCSS et animations fluides
|
||||
- **Page de présentation d'évaluation repensée** :
|
||||
- Hero section avec gradient et informations clés
|
||||
- Actions principales (Noter, Résultats, Modifier, Supprimer) en cards colorées
|
||||
- Indicateur de progression central avec visualisation circulaire animée
|
||||
- Structure d'évaluation en cards compactes avec compétences visibles
|
||||
- **Suppression des pages intermédiaires** : Plus de pages de détail d'exercices, navigation directe
|
||||
- **Indicateurs de progression de correction** : Visualisation immédiate avec cercles de progression et actions intégrées
|
||||
- **Interface cohérente** : Design system unifié avec espacements, couleurs et animations harmonieux
|
||||
|
||||
### **Gestion des Élèves et Import CSV **
|
||||
|
||||
**Gestion Individuelle des Élèves :**
|
||||
|
||||
- **Inscription manuelle** : Création d'élèves un par un avec prénom, nom, email optionnel
|
||||
- **Système d'inscription temporel** : Historique complet avec dates d'arrivée/départ
|
||||
- **Gestion des mouvements** : Transferts entre classes, départs, réintégrations
|
||||
- **Interface moderne** : Modal avec onglets (nouvel élève / élève existant)
|
||||
|
||||
**Import en Lot depuis CSV :**
|
||||
|
||||
- **Format CSV supporté** : Séparateur `;`, première colonne "NOM Prénoms"
|
||||
- **Extraction intelligente** : Reconnaissance automatique nom/prénom ("DUPONT Marie Claire" → nom: "DUPONT", prénom: "Marie Claire")
|
||||
- **Gestion des doublons** : Option pour ignorer ou échouer en cas d'élève existant
|
||||
- **Rapport détaillé** : Statistiques complètes (importés, ignorés, erreurs) avec détail ligne par ligne
|
||||
- **Validation robuste** : Contrôle format CSV, taille fichier, types de données
|
||||
- **Interface intuitive** : Modal avec drag & drop, instructions du format attendu
|
||||
|
||||
**Points d'Accès Multiples :**
|
||||
|
||||
- **Dashboard de classe** : Carte d'action violette "Import CSV"
|
||||
- **Page gestion élèves** : Bouton "Import CSV" dans la barre d'actions
|
||||
- **Navigation cohérente** : Accès contextuel selon le workflow utilisateur
|
||||
|
||||
### **Analyse des Résultats Avancée**
|
||||
|
||||
- **Page de résultats complète** : Vue d'ensemble des performances de l'évaluation
|
||||
- **Statistiques descriptives** : Moyenne, médiane, minimum, maximum, écart-type
|
||||
- **Visualisation graphique** : Histogramme de distribution des notes (groupes de 1 point, de 0 au maximum)
|
||||
- **Tableau détaillé** : Classement alphabétique avec scores par exercice au format "score/total"
|
||||
- **Calcul intelligent des scores** : Gestion des types "points" et "compétences" avec formules spécialisées
|
||||
- **Traitement des absences** : Score "." = 0 point mais compte dans le total possible
|
||||
|
||||
## 🔧 **Structure du Code **
|
||||
|
||||
```
|
||||
app.py # Application Flask principale + routes de base
|
||||
models.py # Modèles SQLAlchemy (5 entités principales + calcul progression)
|
||||
app_config_classes.py # Classes de configuration Flask (dev/prod/test)
|
||||
|
||||
🔧 config/ # Configuration externalisée sécurisée
|
||||
├── __init__.py
|
||||
└── settings.py # Gestion variables d'environnement + validation
|
||||
|
||||
🛡️ exceptions/ # Gestion d'erreurs centralisée
|
||||
├── __init__.py
|
||||
└── handlers.py # Gestionnaires d'erreurs globaux (JSON/HTML)
|
||||
|
||||
🔍 core/ # Utilitaires centraux
|
||||
├── __init__.py
|
||||
└── logging.py # Logging structuré JSON + corrélation requêtes
|
||||
|
||||
📦 repositories/ # Pattern Repository pour accès données
|
||||
├── __init__.py
|
||||
├── base_repository.py # Repository générique CRUD
|
||||
└── assessment_repository.py # Repository spécialisé Assessment
|
||||
|
||||
📁 routes/ # Blueprints organisés par fonctionnalité
|
||||
├── assessments.py # CRUD évaluations (création unifiée)
|
||||
├── exercises.py # Gestion des exercices
|
||||
├── grading.py # Saisie et gestion des notes
|
||||
├── classes.py # Gestion classes + import CSV élèves
|
||||
└── config.py # Interface configuration système
|
||||
|
||||
forms.py # Formulaires WTForms pour validation (+ CSVImportForm)
|
||||
📋 services/ # Services métier modulaires
|
||||
├── csv_import_service.py # Service d'import CSV élèves
|
||||
└── assessment_services.py # Logique métier évaluations
|
||||
utils.py # Utilitaires existants
|
||||
commands.py # Commandes CLI Flask (init-db)
|
||||
templates/ # Templates Jinja2 avec indicateurs UX intégrés
|
||||
📋 domain/ # Exceptions métier personnalisées
|
||||
🧪 tests/ # Tests pytest (100 tests ✅)
|
||||
```
|
||||
|
||||
## 🚀 **Installation & Lancement **
|
||||
|
||||
```bash
|
||||
# Installation avec uv (gestionnaire moderne)
|
||||
uv sync
|
||||
|
||||
# Configuration obligatoire (.env)
|
||||
cp .env.example .env
|
||||
# Modifier .env avec SECRET_KEY (obligatoire, min 32 caractères)
|
||||
|
||||
# Initialisation base de données + données de démo
|
||||
uv run flask --app app init-db
|
||||
|
||||
# Lancement développement avec logging structuré
|
||||
uv run flask --app app run --debug
|
||||
|
||||
# Lancement des tests (100 tests ✅)
|
||||
uv run pytest
|
||||
|
||||
# Consultation des logs structurés JSON
|
||||
tail -f logs/notytex.log
|
||||
```
|
||||
|
||||
## 🧪 **Qualité du Code **
|
||||
|
||||
- **Tests pytest avec 100% de réussite** (100 tests ✅)
|
||||
- **Architecture découplée** : Repository Pattern + Dependency Injection
|
||||
- **Gestion d'erreurs centralisée** : Gestionnaires globaux JSON/HTML
|
||||
- **Logging structuré JSON** : Corrélation des requêtes + contexte complet
|
||||
- **Configuration sécurisée** : Variables d'environnement externalisées
|
||||
- **Validation robuste** : WTForms + Pydantic + services métier
|
||||
- **Séparation des responsabilités** : Modèles/Repositories/Services/Controllers
|
||||
|
||||
## 📝 **Cas d'Usage Typiques**
|
||||
|
||||
### **Scénario A : Évaluation Complète**
|
||||
|
||||
1. **Professeur crée une évaluation** : "Contrôle Chapitre 3 - Fonctions" pour le 2ème trimestre
|
||||
2. **Définit les paramètres** : Date, trimestre (obligatoire), classe, coefficient
|
||||
3. **Ajoute des exercices** : "Exercice 1: Calculs", "Exercice 2: Graphiques"
|
||||
4. **Définit le barème** : Question 1a (2 pts), Question 1b (3 pts), Compétence graphique (score 0-3)
|
||||
5. **Voit l'indicateur de progression** : "Correction 0%" en rouge sur toutes les pages
|
||||
6. **Saisit les notes** pour chaque élève sur chaque élément via clic sur l'indicateur
|
||||
7. **Suit la progression** : L'indicateur passe à "Correction 45%" en orange, puis "Correction 100%" en vert
|
||||
8. **Consulte les résultats détaillés** : Accès direct à la page de résultats avec statistiques et histogramme
|
||||
9. **Analyse les performances** : Statistiques descriptives, distribution des notes et classement alphabétique
|
||||
|
||||
### **Scénario B : Import d'Élèves en Masse (Nouveau)**
|
||||
|
||||
1. **Professeur accède au dashboard** de la classe "6ème A"
|
||||
2. **Clique sur "Import CSV"** depuis la carte d'action violette ou la page des élèves
|
||||
3. **Prépare le fichier CSV** : Export depuis le logiciel administratif avec colonnes séparées par `;`
|
||||
4. **Glisse le fichier** dans la zone de drag & drop ou sélectionne via le bouton
|
||||
5. **Configure l'import** : Date d'inscription, option "ignorer doublons" activée
|
||||
6. **Lance l'import** : Validation automatique format + extraction intelligente des noms
|
||||
7. **Consulte le rapport** : "15 élèves importés, 2 ignorés (doublons), 0 erreur"
|
||||
8. **Vérifie la liste** : Redirection automatique vers la page des élèves mise à jour
|
||||
|
||||
## Volumétrie de milieu d'année
|
||||
|
||||
- 5 classes d'entre 25 et 35 élèves
|
||||
- Les évaluations sont constitués d'entre 10 et 20 éléments de notations
|
||||
- 4 évaluations corrigées par classes pour le premier trimestre
|
||||
- 2 évaluations non corrigée ou partiellement corrigée par classe
|
||||
|
||||
## 🎓 **Public Cible**
|
||||
|
||||
- Enseignants du secondaire (collège/lycée)
|
||||
- Établissements souhaitant digitaliser leurs évaluations
|
||||
- Contexte où coexistent notation classique et évaluation par compétences
|
||||
|
||||
Ce projet présente une architecture solide, une interface soignée avec des **indicateurs UX avancés** pour le suivi de progression, et répond à un besoin concret du monde éducatif en combinant praticité et modernité technique.
|
||||
|
||||
## 🎨 **Dernières Améliorations UX**
|
||||
|
||||
### **Indicateurs de Progression Intégrés**
|
||||
|
||||
- **Calcul automatique** : Propriété `grading_progress` dans le modèle Assessment
|
||||
- **Affichage multi-pages** : Présent sur index, liste évaluations, détail évaluation
|
||||
- **Code couleur intuitif** :
|
||||
- 🔴 Rouge : "Correction 0%" (non commencée)
|
||||
- 🟠 Orange : "Correction XX%" (en cours avec cercle de progression)
|
||||
- 🟢 Vert : "Correction 100%" (terminée)
|
||||
- **Actions directes** : Clic sur l'indicateur → redirection vers page de notation
|
||||
- **Informations détaillées** : "X/Y notes saisies (Z élèves)"
|
||||
- **Responsive design** : Version complète sur liste évaluations, version compacte sur index
|
||||
|
||||
### **Système de Résultats et Statistiques**
|
||||
|
||||
- **Calculs automatisés** : Méthodes `calculate_student_scores()`, `get_assessment_statistics()` dans le modèle Assessment
|
||||
- **Double logique de scoring** :
|
||||
- **Points** : Sommation directe des valeurs
|
||||
- **Compétences** : Formule `1/3 * score * pointMax` (score 0-3)
|
||||
- **Gestion des cas particuliers** : Les scores "." comptent comme 0 mais incluent les points maximum
|
||||
- **Arrondi intelligent** : Notes totales arrondies à 2 décimales pour la précision
|
||||
- **Interface graphique** : Chart.js pour histogrammes interactifs avec bins de 1 point
|
||||
- **Tri alphabétique** : Classement automatique par nom de famille puis prénom
|
||||
|
||||
Cette évolution transforme Notytex en un outil **véritablement centré utilisateur** où l'état de correction est **visible et actionnable depuis n'importe quelle page**, avec une **analyse statistique complète** des résultats.
|
||||
Tous les modèles sont dans `backend/infrastructure/database/models.py`.
|
||||
|
||||
---
|
||||
|
||||
# 🚀 **Guide de Démarrage pour Nouveaux Développeurs**
|
||||
## Fonctionnalités Clés
|
||||
|
||||
## 📋 **Prérequis**
|
||||
### Gestion des Classes et Élèves
|
||||
|
||||
### **Environnement de Développement**
|
||||
- CRUD classes avec année scolaire
|
||||
- Inscription temporelle (bitemporal) : dates d'arrivée/départ, transferts, historique
|
||||
- Import CSV en masse (séparateur `;`, format "NOM Prénoms", gestion doublons)
|
||||
- Statistiques par trimestre et par classe
|
||||
|
||||
- **Python 3.8+** : Version recommandée 3.11+
|
||||
- **uv** : Gestionnaire de paquets moderne Python ([installation](https://docs.astral.sh/uv/))
|
||||
- **Git** : Pour le contrôle de version
|
||||
- **IDE recommandé** : VSCode avec extensions Python, Flask, Jinja2
|
||||
### Système d'Évaluation
|
||||
|
||||
### **Connaissances Requises**
|
||||
- Création unifiée (évaluation + exercices + barème en une fois)
|
||||
- Organisation par trimestre (1, 2, 3)
|
||||
- Filtrage avancé (trimestre, classe, statut de correction, tri)
|
||||
- Indicateurs de progression : rouge (0%), orange (en cours), vert (100%)
|
||||
|
||||
- **Python** : Classes, décorateurs, gestion d'erreurs
|
||||
- **Flask** : Routes, templates, blueprints, contexte d'application
|
||||
- **SQLAlchemy** : ORM, relations, requêtes
|
||||
- **HTML/CSS** : TailwindCSS de préférence
|
||||
- **JavaScript** : Manipulation DOM, événements
|
||||
### Notation Dual Configurable
|
||||
|
||||
## ⚡ **Démarrage Rapide (5 minutes)**
|
||||
**2 types de notation (par GradingElement) :**
|
||||
|
||||
```bash
|
||||
# 1. Cloner et installer
|
||||
git clone <repository>
|
||||
cd notytex
|
||||
uv sync
|
||||
1. **`notes`** : valeurs numériques décimales (ex: 15.5/20) — calcul direct
|
||||
2. **`score`** : échelle fixe 0-3 — converti en points : `(value / 3) * max_points`
|
||||
|
||||
# 2. Initialiser la base de données avec données de test
|
||||
uv run flask --app app init-db
|
||||
**Valeurs spéciales (configurables via interface) :**
|
||||
- `.` = pas de réponse (compte comme 0 dans le total)
|
||||
- `d` = dispensé (exclu des calculs)
|
||||
- `a` = absent (compte comme 0)
|
||||
|
||||
# 3. Lancer l'application
|
||||
uv run flask --app app run --debug
|
||||
### Analyse et Statistiques
|
||||
|
||||
# 4. Ouvrir http://localhost:5000
|
||||
```
|
||||
- Statistiques descriptives (moyenne, médiane, écart-type, min/max, quartiles)
|
||||
- Histogrammes de distribution (bins de 1 point, Chart.js)
|
||||
- Heatmaps par compétences et par domaines
|
||||
- Classement alphabétique avec scores par exercice
|
||||
|
||||
## 🏗️ **Architecture Détaillée**
|
||||
### Conseil de Classe
|
||||
|
||||
### **Structure des Fichiers**
|
||||
- Préparation automatique avec données consolidées par trimestre
|
||||
- Saisie et historique des appréciations (brouillon → finalisé)
|
||||
- Vue complète des performances par élève
|
||||
|
||||
### Envoi de Bilans par Email
|
||||
|
||||
- Génération automatique de bilans individualisés (HTML Jinja2)
|
||||
- SMTP configurable (Gmail, Outlook, serveur perso)
|
||||
- Envoi en lot avec rapport détaillé succès/erreurs
|
||||
- Prévisualisation avant envoi
|
||||
|
||||
---
|
||||
|
||||
## Structure du Code
|
||||
|
||||
```
|
||||
notytex/
|
||||
├── 📱 app.py # Point d'entrée Flask + routes principales
|
||||
├── 🗄️ models.py # Modèles SQLAlchemy + logique métier
|
||||
├── ⚙️ app_config.py # Gestionnaire de configuration SQLite
|
||||
├── 🔧 config.py # Configuration Flask (dev/prod/test)
|
||||
├── 🎯 forms.py # Formulaires WTForms + validation
|
||||
├── 🛠️ utils.py # Fonctions utilitaires + gestion erreurs
|
||||
├── 📜 commands.py # Commandes CLI Flask
|
||||
├── 📁 routes/ # Blueprints organisés par fonctionnalité
|
||||
│ ├── assessments.py # CRUD évaluations + création unifiée
|
||||
│ ├── exercises.py # Gestion exercices + éléments de notation
|
||||
│ ├── grading.py # Interface de saisie des notes
|
||||
│ └── config.py # Interface de configuration système
|
||||
├── 📁 templates/ # Templates Jinja2 + composants réutilisables
|
||||
│ ├── base.html # Layout principal + navigation
|
||||
│ ├── components/ # Composants réutilisables
|
||||
│ └── config/ # Interface de configuration
|
||||
├── 📁 static/ # Assets statiques (CSS, JS, images)
|
||||
├── 🧪 tests/ # Tests pytest + fixtures
|
||||
└── 📝 pyproject.toml # Configuration uv + dépendances
|
||||
├── backend/ # API FastAPI
|
||||
│ ├── api/
|
||||
│ │ ├── main.py # App FastAPI + CORS + lifespan
|
||||
│ │ ├── dependencies.py # DI (AsyncSessionDep)
|
||||
│ │ └── routes/
|
||||
│ │ ├── assessments.py # CRUD évaluations + notation + résultats + email
|
||||
│ │ ├── classes.py # CRUD classes + import CSV + stats dashboard
|
||||
│ │ ├── students.py # CRUD élèves + inscriptions
|
||||
│ │ ├── config.py # Configuration système + compétences + domaines
|
||||
│ │ └── council.py # Appréciations de conseil de classe
|
||||
│ │
|
||||
│ ├── schemas/ # Modèles Pydantic (validation I/O)
|
||||
│ │ ├── common.py # BaseSchema, PaginationParams
|
||||
│ │ ├── assessment.py # AssessmentRead, AssessmentCreate...
|
||||
│ │ ├── student.py # StudentRead, StudentCreate...
|
||||
│ │ ├── grading.py # GradeRead, BulkGradeCreate
|
||||
│ │ ├── class_group.py # ClassGroupRead, ClassDashboardStats
|
||||
│ │ ├── config.py # ConfigRead, CompetenceCreate
|
||||
│ │ ├── council.py # CouncilAppreciationRead
|
||||
│ │ └── csv_import.py # CSVImportResponse
|
||||
│ │
|
||||
│ ├── domain/ # Logique métier pure (aucune dépendance framework)
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── grading_calculator.py # Strategy Pattern (Notes/Score)
|
||||
│ │ │ ├── statistics_service.py # Statistiques descriptives
|
||||
│ │ │ ├── score_calculator.py # Calcul scores élèves
|
||||
│ │ │ ├── config_service.py # Valeurs spéciales, signification scores
|
||||
│ │ │ ├── student_report_service.py # Génération bilans email
|
||||
│ │ │ └── class_statistics_service.py # Stats par classe
|
||||
│ │ └── value_objects/
|
||||
│ │ ├── progress.py # ProgressResult, ProgressStatus
|
||||
│ │ ├── score.py # GradeValue, ExerciseScore, StudentScore
|
||||
│ │ └── statistics.py # StatisticsResult, HistogramBin
|
||||
│ │
|
||||
│ ├── infrastructure/
|
||||
│ │ ├── database/
|
||||
│ │ │ ├── models.py # 12 modèles SQLAlchemy
|
||||
│ │ │ └── session.py # AsyncSession factory
|
||||
│ │ └── external/
|
||||
│ │ └── email_service.py # Service SMTP
|
||||
│ │
|
||||
│ ├── core/
|
||||
│ │ └── config.py # pydantic-settings (Settings)
|
||||
│ │
|
||||
│ ├── tests/ # 99 tests
|
||||
│ │ ├── conftest.py # Fixtures (SQLite in-memory, AsyncClient)
|
||||
│ │ ├── unit/ # Tests services domaine
|
||||
│ │ ├── integration/ # Tests endpoints API
|
||||
│ │ └── comparison/ # Tests parité v1 ↔ v2
|
||||
│ │
|
||||
│ ├── pyproject.toml # Dépendances + config Black/Ruff/pytest
|
||||
│ └── uv.lock
|
||||
│
|
||||
├── frontend/ # SPA Vue.js 3
|
||||
│ ├── src/
|
||||
│ │ ├── main.js # Initialisation app
|
||||
│ │ ├── App.vue # Composant racine
|
||||
│ │ ├── router/index.js # 14 routes Vue Router
|
||||
│ │ ├── stores/ # Pinia (classes, assessments, config, notifications)
|
||||
│ │ ├── services/api.js # Instance Axios (/api/v2)
|
||||
│ │ ├── views/ # 14 pages (Dashboard, Grading, Results...)
|
||||
│ │ ├── components/ # 16+ composants réutilisables
|
||||
│ │ └── assets/
|
||||
│ ├── vite.config.js # Proxy dev /api → localhost:8000
|
||||
│ ├── tailwind.config.js
|
||||
│ └── package.json
|
||||
│
|
||||
├── docker/
|
||||
│ ├── docker-compose.yaml # Production (ports 8080/8081)
|
||||
│ ├── docker-compose.dev.yaml # Développement avec hot reload
|
||||
│ └── .env.example # Template variables Docker
|
||||
│
|
||||
├── school_management.db # Base SQLite partagée
|
||||
├── CLAUDE.md # Ce fichier
|
||||
└── README.md # Documentation utilisateur
|
||||
```
|
||||
|
||||
### **Flux de Données Typique**
|
||||
|
||||
```
|
||||
1. Route Flask (routes/*.py)
|
||||
↓
|
||||
2. Validation Form (forms.py)
|
||||
↓
|
||||
3. Logique Métier (models.py)
|
||||
↓
|
||||
4. Accès Base de Données (SQLAlchemy)
|
||||
↓
|
||||
5. Rendu Template (templates/*.html)
|
||||
```
|
||||
|
||||
## 🎯 **Points d'Entrée pour Contribuer**
|
||||
|
||||
### **🌟 Débutant - Familiarisation**
|
||||
|
||||
1. **Ajouter un champ à un modèle existant**
|
||||
- Fichier : `models.py`
|
||||
- Exemple : Ajouter un champ "commentaire" à Student
|
||||
- Impact : Migration DB + template + form
|
||||
|
||||
2. **Modifier l'apparence d'une page**
|
||||
- Fichiers : `templates/*.html`
|
||||
- Technologie : TailwindCSS
|
||||
- Exemple : Changer les couleurs du dashboard
|
||||
|
||||
3. **Ajouter une validation de formulaire**
|
||||
- Fichier : `forms.py`
|
||||
- Technologie : WTForms
|
||||
- Exemple : Validation format email étudiant
|
||||
|
||||
### **🔥 Intermédiaire - Nouvelles Fonctionnalités**
|
||||
|
||||
1. **Créer une nouvelle page**
|
||||
- Blueprint dans `routes/`
|
||||
- Template correspondant
|
||||
- Formulaire si nécessaire
|
||||
- Tests
|
||||
|
||||
2. **Ajouter un système d'export**
|
||||
- Route d'export (PDF, Excel, CSV)
|
||||
- Template de génération
|
||||
- Boutons dans l'interface
|
||||
|
||||
3. **Étendre le système de configuration**
|
||||
- Nouveau modèle dans `models.py`
|
||||
- Interface dans `routes/config.py`
|
||||
- Template de configuration
|
||||
|
||||
### **⚡ Avancé - Architecture**
|
||||
|
||||
1. **Optimiser les performances**
|
||||
- Requêtes SQLAlchemy (N+1 queries)
|
||||
- Cache des calculs coûteux
|
||||
- Lazy loading intelligent
|
||||
|
||||
2. **Ajouter des API REST**
|
||||
- Endpoints JSON
|
||||
- Authentification
|
||||
- Documentation OpenAPI
|
||||
|
||||
3. **Système de notifications**
|
||||
- Modèles de notifications
|
||||
- Interface utilisateur
|
||||
- Système de stockage
|
||||
|
||||
## 📚 **Concepts Clés à Maîtriser**
|
||||
|
||||
### **Configuration Dynamique**
|
||||
|
||||
```python
|
||||
# Configuration stockée en base SQLite
|
||||
from app_config import config_manager
|
||||
|
||||
# Lecture
|
||||
school_year = config_manager.get('context.school_year')
|
||||
competences = config_manager.get_competences_list()
|
||||
|
||||
# Écriture
|
||||
config_manager.set('context.school_year', '2025-2026')
|
||||
config_manager.save()
|
||||
```
|
||||
|
||||
### **Calcul de Progression**
|
||||
|
||||
```python
|
||||
# Dans models.py - Assessment
|
||||
@property
|
||||
def grading_progress(self):
|
||||
# Calcul automatique du % de correction
|
||||
# Utilisé partout dans l'interface
|
||||
return {
|
||||
'percentage': 75,
|
||||
'status': 'in_progress',
|
||||
'completed': 45,
|
||||
'total': 60
|
||||
}
|
||||
```
|
||||
|
||||
### **Système de Notation Unifié**
|
||||
|
||||
```python
|
||||
# Type "notes" - Valeurs numériques
|
||||
grade.value = "15.5" # Points décimaux
|
||||
grade.grading_element.grading_type = "notes"
|
||||
grade.grading_element.max_points = 20
|
||||
|
||||
# Type "score" - Échelle 0-3 fixe
|
||||
grade.value = "2" # 0=Non acquis, 1=En cours, 2=Acquis, 3=Expert
|
||||
grade.grading_element.grading_type = "score"
|
||||
grade.grading_element.max_points = 3 # Toujours 3 pour les scores
|
||||
|
||||
# Valeurs spéciales configurables
|
||||
grade.value = "." # Pas de réponse (= 0)
|
||||
grade.value = "d" # Dispensé (ne compte pas)
|
||||
|
||||
# Configuration centralisée
|
||||
from app_config import config_manager
|
||||
score_meanings = config_manager.get('grading.score_meanings')
|
||||
special_values = config_manager.get('grading.special_values')
|
||||
```
|
||||
|
||||
## 🧪 **Tests et Débogage**
|
||||
|
||||
### **Lancer les Tests**
|
||||
|
||||
```bash
|
||||
# Tous les tests
|
||||
uv run pytest
|
||||
|
||||
# Tests avec couverture
|
||||
uv run pytest --cov=. --cov-report=html
|
||||
|
||||
# Test spécifique de l'import CSV
|
||||
uv run python -c "
|
||||
from services.csv_import_service import CSVImportService
|
||||
service = CSVImportService()
|
||||
print('✅ Test extraction:', service.extract_name_parts('DUPONT Marie Claire'))
|
||||
"
|
||||
|
||||
# Tests spécifiques
|
||||
uv run pytest tests/test_models.py -v
|
||||
```
|
||||
|
||||
### **Débogage**
|
||||
|
||||
```bash
|
||||
# Mode debug avec rechargement auto
|
||||
uv run flask --app app run --debug
|
||||
|
||||
# Console interactive
|
||||
uv run flask --app app shell
|
||||
|
||||
# Logs détaillés
|
||||
tail -f logs/school_management.log
|
||||
```
|
||||
|
||||
### **Base de Données**
|
||||
|
||||
```bash
|
||||
# Réinitialiser complètement
|
||||
rm school_management.db
|
||||
uv run flask --app app init-db
|
||||
|
||||
# Inspecter la DB
|
||||
sqlite3 school_management.db
|
||||
.tables
|
||||
.schema assessment
|
||||
```
|
||||
|
||||
## 🎨 **Conventions de Code**
|
||||
|
||||
### **Style Python**
|
||||
|
||||
- **PEP 8** : Formatage automatique avec black
|
||||
- **Type hints** : Recommandés pour les nouvelles fonctions
|
||||
- **Docstrings** : Format Google pour les fonctions publiques
|
||||
- **Noms explicites** : `calculate_student_scores()` plutôt que `calc()`
|
||||
|
||||
### **Templates Jinja2**
|
||||
|
||||
- **Indentation** : 4 espaces
|
||||
- **Noms de variables** : snake_case
|
||||
- **Blocs réutilisables** : Utiliser les includes et macros
|
||||
- **Classes CSS** : TailwindCSS avec composition
|
||||
|
||||
### **Base de Données**
|
||||
|
||||
- **Noms de tables** : Pluriel en anglais (`students`, `assessments`)
|
||||
- **Relations** : Toujours avec `backref` explicite
|
||||
- **Cascades** : Définir explicitement le comportement
|
||||
|
||||
## 🐛 **Problèmes Courants**
|
||||
|
||||
### **Erreur : Template Not Found**
|
||||
|
||||
```python
|
||||
# ❌ Mauvais
|
||||
return render_template('config.html')
|
||||
|
||||
# ✅ Correct
|
||||
return render_template('config/index.html')
|
||||
```
|
||||
|
||||
### **Erreur : SQLAlchemy Session**
|
||||
|
||||
```python
|
||||
# ❌ Oublier de commit
|
||||
db.session.add(new_student)
|
||||
|
||||
# ✅ Correct
|
||||
db.session.add(new_student)
|
||||
db.session.commit()
|
||||
```
|
||||
|
||||
### **Erreur : Import Circulaire**
|
||||
|
||||
```python
|
||||
# ❌ Import direct dans models.py
|
||||
from app import app
|
||||
|
||||
# ✅ Import dans fonction
|
||||
def get_current_app():
|
||||
from flask import current_app
|
||||
return current_app
|
||||
```
|
||||
|
||||
## 📖 **Ressources Utiles**
|
||||
|
||||
### **Documentation Officielle**
|
||||
|
||||
- [Flask](https://flask.palletsprojects.com/) - Framework web
|
||||
- [SQLAlchemy](https://docs.sqlalchemy.org/) - ORM Python
|
||||
- [TailwindCSS](https://tailwindcss.com/) - Framework CSS
|
||||
- [Jinja2](https://jinja.palletsprojects.com/) - Moteur de templates
|
||||
|
||||
### **Outils de Développement**
|
||||
|
||||
- [uv](https://docs.astral.sh/uv/) - Gestionnaire de paquets
|
||||
- [pytest](https://docs.pytest.org/) - Framework de tests
|
||||
- [Flask-Shell](https://flask.palletsprojects.com/en/2.3.x/shell/) - Console interactive
|
||||
|
||||
---
|
||||
|
||||
## 📧 **Système d'Envoi de Bilans par Email**
|
||||
## Installation et Lancement
|
||||
|
||||
**Notytex** intègre désormais un système complet d'envoi automatique des bilans d'évaluation individuels par email aux élèves et à leurs familles.
|
||||
### Prérequis
|
||||
|
||||
### **🎯 Fonctionnalités Principales**
|
||||
- Python 3.11-3.13 avec [uv](https://docs.astral.sh/uv/)
|
||||
- Node.js 22 LTS avec npm
|
||||
- Git
|
||||
|
||||
- ✅ **Configuration SMTP flexible** via interface web (Gmail, Outlook, serveurs personnalisés)
|
||||
- ✅ **Génération automatique** de bilans individualisés avec analyses détaillées
|
||||
- ✅ **Templates HTML responsives** optimisés pour tous clients email
|
||||
- ✅ **Analyses par compétences et domaines** avec visualisations graphiques
|
||||
- ✅ **Serveur de test intégré** (`debug_smtp_server.py`) pour développement
|
||||
- ✅ **Gestion d'erreurs robuste** avec logs structurés et rapports détaillés
|
||||
### Backend
|
||||
|
||||
### **🏗️ Architecture**
|
||||
|
||||
```
|
||||
services/
|
||||
├── email_service.py # Service d'envoi SMTP
|
||||
└── student_report_service.py # Génération de bilans
|
||||
|
||||
templates/email/
|
||||
├── base_email.html # Template de base responsive
|
||||
└── student_report.html # Template de bilan détaillé
|
||||
```bash
|
||||
cd backend
|
||||
uv sync --all-extras
|
||||
uv run python -m uvicorn api.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### **📊 Contenu des Bilans**
|
||||
- API : http://localhost:8000/api/v2/docs (Swagger)
|
||||
- ReDoc : http://localhost:8000/api/v2/redoc
|
||||
|
||||
Chaque bilan inclut :
|
||||
### Frontend
|
||||
|
||||
- **Note globale** avec visualisation colorée et position dans la classe
|
||||
- **Détail par exercice** et questions individuelles
|
||||
- **Analyses par compétences** avec système d'étoiles visuelles
|
||||
- **Performances par domaines** avec codes couleur
|
||||
- **Statistiques de classe** (moyenne, médiane, écart-type)
|
||||
- **Message personnalisé** du professeur (optionnel)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### **📧 Workflow d'Envoi**
|
||||
- Application : http://localhost:3000 (proxy automatique `/api` vers le backend)
|
||||
|
||||
1. **Configuration SMTP** via `Configuration > Email`
|
||||
2. **Sélection d'élèves** depuis la page de résultats d'évaluation
|
||||
3. **Prévisualisation** des bilans via `/preview-report`
|
||||
4. **Envoi groupé** avec rapport détaillé des succès/erreurs
|
||||
5. **Réception professionnelle** par les élèves et familles
|
||||
### Docker / Podman
|
||||
|
||||
**📖 Documentation complète** : [docs/features/EMAIL_REPORTS.md](docs/features/EMAIL_REPORTS.md)
|
||||
```bash
|
||||
cd docker
|
||||
cp .env.example .env
|
||||
# Modifier SECRET_KEY dans .env
|
||||
|
||||
docker compose up -d # ou podman-compose up -d
|
||||
```
|
||||
|
||||
- Frontend : http://localhost:8081
|
||||
- API : http://localhost:8080/api/v2/docs
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Tous les tests
|
||||
uv run pytest tests/ -v
|
||||
|
||||
# Tests unitaires
|
||||
uv run pytest tests/unit/ -v
|
||||
|
||||
# Tests de parité v1 ↔ v2
|
||||
uv run pytest tests/comparison/ -v
|
||||
|
||||
# Avec couverture
|
||||
uv run pytest tests/ --cov=. --cov-report=html
|
||||
```
|
||||
|
||||
**Résultat actuel : 99/99 tests**
|
||||
|
||||
Les tests utilisent une base SQLite in-memory avec `AsyncClient` + `ASGITransport` pour tester les endpoints sans serveur.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### pydantic-settings (`backend/core/config.py`)
|
||||
|
||||
```python
|
||||
class Settings(BaseSettings):
|
||||
# Base de données
|
||||
database_url: Optional[str] # URL complète SQLAlchemy async
|
||||
database_path: Optional[str] # Chemin simple vers le .db
|
||||
|
||||
# API
|
||||
api_v2_prefix: str = "/api/v2"
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:5173"]
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
|
||||
# Email (optionnel)
|
||||
smtp_server, smtp_port, smtp_username, smtp_password, email_from
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
```
|
||||
|
||||
Toute la config est lue depuis `backend/.env` et validée par Pydantic au démarrage.
|
||||
|
||||
### Configuration dynamique (table AppConfig)
|
||||
|
||||
Paramètres modifiables à chaud via l'interface web :
|
||||
- `context.school_year` — année scolaire en cours
|
||||
- `grading.special_values` — valeurs spéciales (`.`, `d`, `a`)
|
||||
- `grading.score_meanings` — signification des scores 0-3
|
||||
- Configuration SMTP pour l'envoi d'emails
|
||||
|
||||
---
|
||||
|
||||
## Patterns Architecturaux
|
||||
|
||||
### Strategy Pattern — Calcul de notation
|
||||
`domain/services/grading_calculator.py` : `GradingStrategy` (ABC) avec `NotesStrategy` et `ScoreStrategy`, sélectionnées par `GradingStrategyFactory` selon le `grading_type` du GradingElement.
|
||||
|
||||
### Value Objects — Objets immuables du domaine
|
||||
`domain/value_objects/` : `ProgressResult`, `GradeValue`, `StudentScore`, `StatisticsResult`, `HistogramBin`. Représentent des résultats de calcul sans identité propre.
|
||||
|
||||
### Dependency Injection — FastAPI natif
|
||||
```python
|
||||
AsyncSessionDep = Annotated[AsyncSession, Depends(get_async_session)]
|
||||
```
|
||||
Injection de la session DB dans chaque route, facilitant le remplacement en tests.
|
||||
|
||||
### Service Layer — Logique métier découplée
|
||||
Les services dans `domain/services/` n'ont aucune dépendance sur FastAPI ou SQLAlchemy. Ils reçoivent des données brutes et retournent des Value Objects.
|
||||
|
||||
### Clean Architecture — Séparation en couches
|
||||
`api/` (routes) → `schemas/` (validation) → `domain/` (métier) → `infrastructure/` (DB, email). Les dépendances pointent toujours vers l'intérieur.
|
||||
|
||||
---
|
||||
|
||||
## Conventions de Code
|
||||
|
||||
### Backend (Python)
|
||||
|
||||
- **Formatage** : `black` (line-length 88, target py311)
|
||||
- **Linting** : `ruff` (pycodestyle, pyflakes, isort, bugbear, comprehensions)
|
||||
- **Types** : `mypy` recommandé pour les nouvelles fonctions
|
||||
- **Async** : toutes les routes et accès DB sont `async/await`
|
||||
- **Noms** : snake_case, noms explicites (`calculate_student_scores`, pas `calc`)
|
||||
|
||||
### Frontend (JavaScript/Vue)
|
||||
|
||||
- **Composition API** avec `<script setup>`
|
||||
- **Pinia** stores en style composition (pas Options API)
|
||||
- **TailwindCSS** pour le styling (pas de CSS custom sauf nécessité)
|
||||
- **Axios** via `services/api.js` pour tous les appels API
|
||||
|
||||
### Base de données
|
||||
|
||||
- Noms de tables : pluriel anglais (`students`, `assessments`)
|
||||
- Relations avec `relationship()` et cascade explicite
|
||||
- Modèles identiques à v1 pour compatibilité de la base partagée
|
||||
|
||||
---
|
||||
|
||||
## Volumétrie (milieu d'année)
|
||||
|
||||
- 5 classes de 25 à 35 élèves
|
||||
- 10 à 20 éléments de notation par évaluation
|
||||
- 4 évaluations corrigées par classe pour le 1er trimestre
|
||||
- 2 évaluations non corrigées ou partiellement corrigées par classe
|
||||
|
||||
---
|
||||
|
||||
## Public Cible
|
||||
|
||||
- Enseignants du secondaire (collège/lycée)
|
||||
- Établissements souhaitant digitaliser leurs évaluations
|
||||
- Contexte de coexistence notation classique et évaluation par compétences
|
||||
|
||||
---
|
||||
|
||||
## Workflow Développeur
|
||||
|
||||
### Ajouter un endpoint API
|
||||
|
||||
1. Créer le schéma Pydantic dans `backend/schemas/`
|
||||
2. Ajouter la route dans `backend/api/routes/`
|
||||
3. Utiliser `AsyncSessionDep` pour l'accès DB
|
||||
4. Écrire les tests dans `backend/tests/`
|
||||
|
||||
### Ajouter une page frontend
|
||||
|
||||
1. Créer le composant Vue dans `frontend/src/views/`
|
||||
2. Ajouter la route dans `frontend/src/router/index.js`
|
||||
3. Créer un store Pinia si nécessaire dans `frontend/src/stores/`
|
||||
4. Réutiliser les composants existants de `frontend/src/components/`
|
||||
|
||||
### Modifier la logique de notation
|
||||
|
||||
1. Modifier `backend/domain/services/grading_calculator.py`
|
||||
2. Mettre à jour les tests dans `backend/tests/unit/test_grading_calculator.py`
|
||||
3. Lancer les tests de parité (`tests/comparison/`) pour vérifier la compatibilité v1
|
||||
|
||||
### Débogage
|
||||
|
||||
```bash
|
||||
# Backend avec rechargement auto
|
||||
cd backend && uv run python -m uvicorn api.main:app --reload --port 8000
|
||||
|
||||
# Inspecter la base
|
||||
sqlite3 school_management.db ".schema assessment"
|
||||
|
||||
# Console interactive
|
||||
cd backend && uv run python -c "from infrastructure.database.models import *; print('OK')"
|
||||
```
|
||||
|
||||
196
backend/api/helpers.py
Normal file
196
backend/api/helpers.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Fonctions utilitaires partagées entre les routes API.
|
||||
Élimine la duplication de code pour les patterns courants.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from typing import Optional, Dict, List, Tuple, Any, Callable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from infrastructure.database.models import (
|
||||
AppConfig,
|
||||
StudentEnrollment,
|
||||
Student,
|
||||
)
|
||||
from schemas.assessment import HeatmapCell, HeatmapData
|
||||
|
||||
|
||||
def eligible_enrollment_filter(class_group_id: int, at_date: date):
|
||||
"""
|
||||
Retourne les clauses WHERE pour filtrer les inscriptions actives à une date donnée.
|
||||
|
||||
Usage:
|
||||
query = select(StudentEnrollment).where(
|
||||
*eligible_enrollment_filter(class_id, assessment.date)
|
||||
)
|
||||
"""
|
||||
return (
|
||||
StudentEnrollment.class_group_id == class_group_id,
|
||||
StudentEnrollment.enrollment_date <= at_date,
|
||||
(
|
||||
StudentEnrollment.departure_date.is_(None)
|
||||
| (StudentEnrollment.departure_date >= at_date)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def count_eligible_students(
|
||||
session: AsyncSession, class_group_id: int, at_date: date
|
||||
) -> int:
|
||||
"""Compte les élèves inscrits dans une classe à une date donnée."""
|
||||
query = select(func.count(StudentEnrollment.id)).where(
|
||||
*eligible_enrollment_filter(class_group_id, at_date)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
def eligible_student_ids_subquery(class_group_id: int, at_date: date):
|
||||
"""Retourne une sous-requête des student_id éligibles à une date donnée."""
|
||||
return select(StudentEnrollment.student_id).where(
|
||||
*eligible_enrollment_filter(class_group_id, at_date)
|
||||
)
|
||||
|
||||
|
||||
async def get_eligible_enrollments(
|
||||
session: AsyncSession, class_group_id: int, at_date: date
|
||||
):
|
||||
"""Récupère les inscriptions éligibles avec les étudiants chargés."""
|
||||
query = (
|
||||
select(StudentEnrollment)
|
||||
.options(selectinload(StudentEnrollment.student))
|
||||
.where(*eligible_enrollment_filter(class_group_id, at_date))
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
def get_active_enrollment(
|
||||
student: Student,
|
||||
) -> Optional[StudentEnrollment]:
|
||||
"""Retourne l'inscription active (sans date de départ) d'un élève."""
|
||||
for enrollment in student.enrollments:
|
||||
if enrollment.departure_date is None:
|
||||
return enrollment
|
||||
return None
|
||||
|
||||
|
||||
async def ensure_unique_name(
|
||||
session: AsyncSession,
|
||||
model_class,
|
||||
name: str,
|
||||
*,
|
||||
field_name: str = "name",
|
||||
exclude_id: Optional[int] = None,
|
||||
entity_label: str = "enregistrement",
|
||||
):
|
||||
"""
|
||||
Vérifie qu'un nom est unique pour un modèle donné.
|
||||
Lève HTTPException 400 si un doublon est trouvé.
|
||||
"""
|
||||
field = getattr(model_class, field_name)
|
||||
query = select(model_class).where(field == name)
|
||||
|
||||
if exclude_id is not None:
|
||||
query = query.where(model_class.id != exclude_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Un(e) {entity_label} avec le nom '{name}' existe déjà",
|
||||
)
|
||||
|
||||
|
||||
async def upsert_app_configs(
|
||||
session: AsyncSession, updates: Dict[str, str]
|
||||
) -> None:
|
||||
"""
|
||||
Met à jour ou crée des entrées AppConfig en lot.
|
||||
Ne fait PAS de commit — l'appelant doit appeler session.commit().
|
||||
"""
|
||||
for key, value in updates.items():
|
||||
query = select(AppConfig).where(AppConfig.key == key)
|
||||
result = await session.execute(query)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config:
|
||||
config.value = value
|
||||
else:
|
||||
session.add(AppConfig(key=key, value=value))
|
||||
|
||||
|
||||
def build_heatmap(
|
||||
enrollments,
|
||||
assessment,
|
||||
items: dict,
|
||||
item_extractor: Callable,
|
||||
grading_calc,
|
||||
sorted_student_names: List[str],
|
||||
*,
|
||||
color_map: Optional[Dict[str, str]] = None,
|
||||
) -> Optional[HeatmapData]:
|
||||
"""
|
||||
Construit une HeatmapData pour des compétences ou domaines.
|
||||
|
||||
Args:
|
||||
enrollments: liste d'inscriptions avec .student chargé
|
||||
assessment: l'évaluation avec .exercises chargés
|
||||
items: set ou dict des items à évaluer (noms)
|
||||
item_extractor: fonction (element) -> nom de l'item ou None
|
||||
grading_calc: instance de GradingCalculator
|
||||
sorted_student_names: noms triés pour l'axe Y
|
||||
color_map: optionnel, dict nom -> couleur pour les cellules
|
||||
"""
|
||||
if not items:
|
||||
return None
|
||||
|
||||
item_names = set(items) if isinstance(items, dict) else items
|
||||
cells = []
|
||||
|
||||
for enrollment in enrollments:
|
||||
student = enrollment.student
|
||||
student_name = student.full_name_reversed
|
||||
|
||||
item_scores = {name: {"score": 0.0, "max": 0.0} for name in item_names}
|
||||
|
||||
for exercise in assessment.exercises:
|
||||
for element in exercise.grading_elements:
|
||||
item_name = item_extractor(element)
|
||||
if item_name and item_name in item_scores:
|
||||
for g in element.grades:
|
||||
if g.student_id == student.id and g.value:
|
||||
calc_score = grading_calc.calculate_score(
|
||||
g.value.strip(),
|
||||
element.grading_type,
|
||||
element.max_points,
|
||||
)
|
||||
if calc_score is not None:
|
||||
item_scores[item_name]["score"] += calc_score
|
||||
item_scores[item_name]["max"] += element.max_points
|
||||
break
|
||||
|
||||
for name, data in item_scores.items():
|
||||
if data["max"] > 0:
|
||||
pct = round((data["score"] / data["max"]) * 100, 1)
|
||||
cell_kwargs = dict(
|
||||
student_id=student.id,
|
||||
student_name=student_name,
|
||||
item_name=name,
|
||||
score=round(data["score"], 2),
|
||||
max_points=data["max"],
|
||||
percentage=pct,
|
||||
)
|
||||
if color_map and name in color_map:
|
||||
cell_kwargs["color"] = color_map[name]
|
||||
cells.append(HeatmapCell(**cell_kwargs))
|
||||
|
||||
return HeatmapData(
|
||||
items=sorted(list(item_names)),
|
||||
students=sorted_student_names,
|
||||
cells=cells,
|
||||
)
|
||||
@@ -11,6 +11,12 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from api.dependencies import AsyncSessionDep
|
||||
from api.helpers import (
|
||||
count_eligible_students,
|
||||
eligible_student_ids_subquery,
|
||||
get_eligible_enrollments,
|
||||
build_heatmap,
|
||||
)
|
||||
from infrastructure.database.models import (
|
||||
Assessment,
|
||||
Exercise,
|
||||
@@ -36,8 +42,6 @@ from schemas.assessment import (
|
||||
SendReportsRequest,
|
||||
SendReportResult,
|
||||
SendReportsResponse,
|
||||
HeatmapCell,
|
||||
HeatmapData,
|
||||
)
|
||||
from schemas.grading import BulkGradeCreate, BulkGradeResponse, GradeRead
|
||||
from domain.services import GradingCalculator, StatisticsService, StudentReportService, generate_report_html, ConfigService
|
||||
@@ -124,21 +128,13 @@ async def get_assessments(
|
||||
total_elements += 1
|
||||
|
||||
# Compter les élèves éligibles (inscrits à la date de l'évaluation)
|
||||
eligible_query = select(func.count(StudentEnrollment.id)).where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
eligible_students_count = await count_eligible_students(
|
||||
session, assessment.class_group_id, assessment.date
|
||||
)
|
||||
eligible_result = await session.execute(eligible_query)
|
||||
eligible_students_count = eligible_result.scalar() or 0
|
||||
|
||||
|
||||
# Compter les notes saisies uniquement pour les élèves éligibles
|
||||
eligible_student_ids = select(StudentEnrollment.student_id).where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
eligible_student_ids = eligible_student_ids_subquery(
|
||||
assessment.class_group_id, assessment.date
|
||||
)
|
||||
grades_query = select(func.count(Grade.id)).where(
|
||||
Grade.grading_element_id.in_(
|
||||
@@ -239,21 +235,13 @@ async def get_assessment(
|
||||
)
|
||||
|
||||
# Calculer la progression
|
||||
eligible_query = select(func.count(StudentEnrollment.id)).where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
eligible_students_count = await count_eligible_students(
|
||||
session, assessment.class_group_id, assessment.date
|
||||
)
|
||||
eligible_result = await session.execute(eligible_query)
|
||||
eligible_students_count = eligible_result.scalar() or 0
|
||||
|
||||
|
||||
# Compter les notes uniquement pour les élèves éligibles
|
||||
eligible_student_ids = select(StudentEnrollment.student_id).where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
eligible_student_ids = eligible_student_ids_subquery(
|
||||
assessment.class_group_id, assessment.date
|
||||
)
|
||||
grades_query = select(func.count(Grade.id)).where(
|
||||
Grade.grading_element_id.in_(
|
||||
@@ -266,11 +254,11 @@ async def get_assessment(
|
||||
)
|
||||
grades_result = await session.execute(grades_query)
|
||||
grades_count = grades_result.scalar() or 0
|
||||
|
||||
|
||||
progress = calculate_grading_progress(
|
||||
assessment, grades_count, total_elements, eligible_students_count
|
||||
)
|
||||
|
||||
|
||||
return AssessmentDetail(
|
||||
id=assessment.id,
|
||||
title=assessment.title,
|
||||
@@ -322,18 +310,9 @@ async def get_assessment_results(
|
||||
raise HTTPException(status_code=404, detail="Évaluation non trouvée")
|
||||
|
||||
# Récupérer les élèves éligibles
|
||||
eligible_query = (
|
||||
select(StudentEnrollment)
|
||||
.options(selectinload(StudentEnrollment.student))
|
||||
.where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
)
|
||||
enrollments = await get_eligible_enrollments(
|
||||
session, assessment.class_group_id, assessment.date
|
||||
)
|
||||
eligible_result = await session.execute(eligible_query)
|
||||
enrollments = eligible_result.scalars().all()
|
||||
|
||||
# Calculer le total des points maximum
|
||||
total_max_points = 0
|
||||
@@ -392,7 +371,7 @@ async def get_assessment_results(
|
||||
|
||||
student_scores[student_id] = StudentScore(
|
||||
student_id=student_id,
|
||||
student_name=f"{student.last_name} {student.first_name}",
|
||||
student_name=student.full_name_reversed,
|
||||
email=student.email, # Ajouter l'email de l'élève
|
||||
total_score=round(total_score, 2),
|
||||
total_max_points=counted_max,
|
||||
@@ -440,97 +419,25 @@ async def get_assessment_results(
|
||||
all_domains[element.domain.name] = element.domain.color
|
||||
|
||||
# Calculer heatmap des compétences si présentes
|
||||
if all_competences:
|
||||
competences_cells = []
|
||||
|
||||
for enrollment in enrollments:
|
||||
student = enrollment.student
|
||||
student_name = f"{student.last_name} {student.first_name}"
|
||||
|
||||
# Calculer score par compétence pour cet élève
|
||||
competence_scores = {}
|
||||
for comp in all_competences:
|
||||
competence_scores[comp] = {"score": 0.0, "max": 0.0}
|
||||
|
||||
for exercise in assessment.exercises:
|
||||
for element in exercise.grading_elements:
|
||||
if element.skill and element.skill in competence_scores:
|
||||
# Trouver la note
|
||||
for g in element.grades:
|
||||
if g.student_id == student.id and g.value:
|
||||
calc_score = grading_calc.calculate_score(
|
||||
g.value.strip(), element.grading_type, element.max_points
|
||||
)
|
||||
if calc_score is not None:
|
||||
competence_scores[element.skill]["score"] += calc_score
|
||||
competence_scores[element.skill]["max"] += element.max_points
|
||||
break
|
||||
|
||||
# Créer les cellules
|
||||
for comp, data in competence_scores.items():
|
||||
if data["max"] > 0:
|
||||
pct = round((data["score"] / data["max"]) * 100, 1)
|
||||
competences_cells.append(HeatmapCell(
|
||||
student_id=student.id,
|
||||
student_name=student_name,
|
||||
item_name=comp,
|
||||
score=round(data["score"], 2),
|
||||
max_points=data["max"],
|
||||
percentage=pct
|
||||
))
|
||||
|
||||
competences_heatmap = HeatmapData(
|
||||
items=sorted(list(all_competences)),
|
||||
students=[s.student_name for s in sorted_scores],
|
||||
cells=competences_cells
|
||||
)
|
||||
competences_heatmap = build_heatmap(
|
||||
enrollments=enrollments,
|
||||
assessment=assessment,
|
||||
items=all_competences,
|
||||
item_extractor=lambda el: el.skill,
|
||||
grading_calc=grading_calc,
|
||||
sorted_student_names=[s.student_name for s in sorted_scores],
|
||||
)
|
||||
|
||||
# Calculer heatmap des domaines si présents
|
||||
if all_domains:
|
||||
domains_cells = []
|
||||
|
||||
for enrollment in enrollments:
|
||||
student = enrollment.student
|
||||
student_name = f"{student.last_name} {student.first_name}"
|
||||
|
||||
# Calculer score par domaine pour cet élève
|
||||
domain_scores = {}
|
||||
for dom in all_domains:
|
||||
domain_scores[dom] = {"score": 0.0, "max": 0.0}
|
||||
|
||||
for exercise in assessment.exercises:
|
||||
for element in exercise.grading_elements:
|
||||
if element.domain and element.domain.name in domain_scores:
|
||||
# Trouver la note
|
||||
for g in element.grades:
|
||||
if g.student_id == student.id and g.value:
|
||||
calc_score = grading_calc.calculate_score(
|
||||
g.value.strip(), element.grading_type, element.max_points
|
||||
)
|
||||
if calc_score is not None:
|
||||
domain_scores[element.domain.name]["score"] += calc_score
|
||||
domain_scores[element.domain.name]["max"] += element.max_points
|
||||
break
|
||||
|
||||
# Créer les cellules
|
||||
for dom, data in domain_scores.items():
|
||||
if data["max"] > 0:
|
||||
pct = round((data["score"] / data["max"]) * 100, 1)
|
||||
domains_cells.append(HeatmapCell(
|
||||
student_id=student.id,
|
||||
student_name=student_name,
|
||||
item_name=dom,
|
||||
score=round(data["score"], 2),
|
||||
max_points=data["max"],
|
||||
percentage=pct,
|
||||
color=all_domains.get(dom)
|
||||
))
|
||||
|
||||
domains_heatmap = HeatmapData(
|
||||
items=sorted(list(all_domains.keys())),
|
||||
students=[s.student_name for s in sorted_scores],
|
||||
cells=domains_cells
|
||||
)
|
||||
domains_heatmap = build_heatmap(
|
||||
enrollments=enrollments,
|
||||
assessment=assessment,
|
||||
items=all_domains,
|
||||
item_extractor=lambda el: el.domain.name if el.domain else None,
|
||||
grading_calc=grading_calc,
|
||||
sorted_student_names=[s.student_name for s in sorted_scores],
|
||||
color_map=all_domains,
|
||||
)
|
||||
|
||||
return AssessmentResults(
|
||||
assessment_id=assessment.id,
|
||||
@@ -827,21 +734,13 @@ async def update_assessment(
|
||||
)
|
||||
|
||||
# Calculer la progression
|
||||
eligible_query = select(func.count(StudentEnrollment.id)).where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
eligible_students_count = await count_eligible_students(
|
||||
session, assessment.class_group_id, assessment.date
|
||||
)
|
||||
eligible_result = await session.execute(eligible_query)
|
||||
eligible_students_count = eligible_result.scalar() or 0
|
||||
|
||||
|
||||
# Compter les notes uniquement pour les élèves éligibles
|
||||
eligible_student_ids = select(StudentEnrollment.student_id).where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
eligible_student_ids = eligible_student_ids_subquery(
|
||||
assessment.class_group_id, assessment.date
|
||||
)
|
||||
grades_query = select(func.count(Grade.id)).where(
|
||||
Grade.grading_element_id.in_(
|
||||
@@ -854,11 +753,11 @@ async def update_assessment(
|
||||
)
|
||||
grades_result = await session.execute(grades_query)
|
||||
grades_count = grades_result.scalar() or 0
|
||||
|
||||
|
||||
progress = calculate_grading_progress(
|
||||
assessment, grades_count, total_elements, eligible_students_count
|
||||
)
|
||||
|
||||
|
||||
return AssessmentDetail(
|
||||
id=assessment.id,
|
||||
title=assessment.title,
|
||||
@@ -1132,7 +1031,7 @@ async def send_reports(
|
||||
total_failed = 0
|
||||
|
||||
for student in students:
|
||||
student_name = f"{student.first_name} {student.last_name}"
|
||||
student_name = student.full_name
|
||||
|
||||
# Vérifier que l'élève a une adresse email
|
||||
if not student.email:
|
||||
@@ -1307,7 +1206,7 @@ async def preview_report(
|
||||
if student_id not in all_students_grades:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"L'élève {student.first_name} {student.last_name} n'a pas de notes pour cette évaluation"
|
||||
detail=f"L'élève {student.full_name} n'a pas de notes pour cette évaluation"
|
||||
)
|
||||
|
||||
# Créer le service de rapport
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from api.dependencies import AsyncSessionDep
|
||||
from api.helpers import ensure_unique_name
|
||||
from infrastructure.database.models import (
|
||||
ClassGroup,
|
||||
Student,
|
||||
@@ -201,7 +202,7 @@ async def get_class_students(
|
||||
last_name=student.last_name,
|
||||
first_name=student.first_name,
|
||||
email=student.email,
|
||||
full_name=f"{student.first_name} {student.last_name}",
|
||||
full_name=student.full_name,
|
||||
current_class_id=class_id if is_active else None,
|
||||
current_class_name=cls.name if is_active else None,
|
||||
enrollment_id=enrollment.id,
|
||||
@@ -419,13 +420,7 @@ async def create_class(
|
||||
Crée une nouvelle classe.
|
||||
"""
|
||||
# Vérifier l'unicité du nom
|
||||
existing_query = select(ClassGroup).where(ClassGroup.name == class_data.name)
|
||||
existing_result = await session.execute(existing_query)
|
||||
if existing_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Une classe avec le nom '{class_data.name}' existe déjà"
|
||||
)
|
||||
await ensure_unique_name(session, ClassGroup, class_data.name, entity_label="classe")
|
||||
|
||||
# Créer la nouvelle classe
|
||||
new_class = ClassGroup(
|
||||
@@ -466,16 +461,10 @@ async def update_class(
|
||||
|
||||
# Vérifier l'unicité du nouveau nom si modifié
|
||||
if class_data.name and class_data.name != cls.name:
|
||||
existing_query = select(ClassGroup).where(
|
||||
ClassGroup.name == class_data.name,
|
||||
ClassGroup.id != class_id
|
||||
await ensure_unique_name(
|
||||
session, ClassGroup, class_data.name,
|
||||
exclude_id=class_id, entity_label="classe"
|
||||
)
|
||||
existing_result = await session.execute(existing_query)
|
||||
if existing_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Une autre classe avec le nom '{class_data.name}' existe déjà"
|
||||
)
|
||||
|
||||
# Appliquer les modifications
|
||||
if class_data.name is not None:
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException
|
||||
from sqlalchemy import select, func, delete
|
||||
|
||||
from api.dependencies import AsyncSessionDep
|
||||
from api.helpers import ensure_unique_name, upsert_app_configs
|
||||
from infrastructure.database.models import (
|
||||
AppConfig,
|
||||
Competence,
|
||||
@@ -228,13 +229,7 @@ async def create_competence(
|
||||
Crée une nouvelle compétence.
|
||||
"""
|
||||
# Vérifier l'unicité du nom
|
||||
existing_query = select(Competence).where(Competence.name == data.name)
|
||||
existing_result = await session.execute(existing_query)
|
||||
if existing_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Une compétence avec le nom '{data.name}' existe déjà"
|
||||
)
|
||||
await ensure_unique_name(session, Competence, data.name, entity_label="compétence")
|
||||
|
||||
# Déterminer l'index d'ordre
|
||||
if data.order_index is None:
|
||||
@@ -284,16 +279,7 @@ async def update_competence(
|
||||
|
||||
# Vérifier l'unicité du nouveau nom
|
||||
if data.name and data.name != competence.name:
|
||||
existing_query = select(Competence).where(
|
||||
Competence.name == data.name,
|
||||
Competence.id != competence_id
|
||||
)
|
||||
existing_result = await session.execute(existing_query)
|
||||
if existing_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Une autre compétence avec le nom '{data.name}' existe déjà"
|
||||
)
|
||||
await ensure_unique_name(session, Competence, data.name, exclude_id=competence_id, entity_label="compétence")
|
||||
|
||||
# Appliquer les modifications
|
||||
if data.name is not None:
|
||||
@@ -353,13 +339,7 @@ async def create_domain(
|
||||
Crée un nouveau domaine.
|
||||
"""
|
||||
# Vérifier l'unicité du nom
|
||||
existing_query = select(Domain).where(Domain.name == data.name)
|
||||
existing_result = await session.execute(existing_query)
|
||||
if existing_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Un domaine avec le nom '{data.name}' existe déjà"
|
||||
)
|
||||
await ensure_unique_name(session, Domain, data.name, entity_label="domaine")
|
||||
|
||||
# Créer le domaine
|
||||
domain = Domain(
|
||||
@@ -398,16 +378,7 @@ async def update_domain(
|
||||
|
||||
# Vérifier l'unicité du nouveau nom
|
||||
if data.name and data.name != domain.name:
|
||||
existing_query = select(Domain).where(
|
||||
Domain.name == data.name,
|
||||
Domain.id != domain_id
|
||||
)
|
||||
existing_result = await session.execute(existing_query)
|
||||
if existing_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Un autre domaine avec le nom '{data.name}' existe déjà"
|
||||
)
|
||||
await ensure_unique_name(session, Domain, data.name, exclude_id=domain_id, entity_label="domaine")
|
||||
|
||||
# Appliquer les modifications
|
||||
if data.name is not None:
|
||||
@@ -669,18 +640,7 @@ async def update_app_config(
|
||||
if data.default_grading_system is not None:
|
||||
updates.append(("default_grading_system", data.default_grading_system))
|
||||
|
||||
for key, value in updates:
|
||||
# Chercher la config existante
|
||||
query = select(AppConfig).where(AppConfig.key == key)
|
||||
result = await session.execute(query)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config:
|
||||
config.value = value
|
||||
else:
|
||||
# Créer si n'existe pas
|
||||
new_config = AppConfig(key=key, value=value)
|
||||
session.add(new_config)
|
||||
await upsert_app_configs(session, dict(updates))
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -755,18 +715,7 @@ async def update_smtp_config(
|
||||
if data.from_address is not None:
|
||||
updates.append(("email.from_address", data.from_address))
|
||||
|
||||
for key, value in updates:
|
||||
# Chercher la config existante
|
||||
query = select(AppConfig).where(AppConfig.key == key)
|
||||
result = await session.execute(query)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config:
|
||||
config.value = value
|
||||
else:
|
||||
# Créer si n'existe pas
|
||||
new_config = AppConfig(key=key, value=value)
|
||||
session.add(new_config)
|
||||
await upsert_app_configs(session, dict(updates))
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -843,16 +792,7 @@ async def update_notes_gradient(
|
||||
if data.enabled is not None:
|
||||
updates.append(("grading.notes_gradient.enabled", "true" if data.enabled else "false"))
|
||||
|
||||
for key, value in updates:
|
||||
query = select(AppConfig).where(AppConfig.key == key)
|
||||
result = await session.execute(query)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config:
|
||||
config.value = value
|
||||
else:
|
||||
new_config = AppConfig(key=key, value=value)
|
||||
session.add(new_config)
|
||||
await upsert_app_configs(session, dict(updates))
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ async def get_council_preparation(
|
||||
student_id=student.id,
|
||||
first_name=student.first_name,
|
||||
last_name=student.last_name,
|
||||
full_name=f"{student.first_name} {student.last_name}",
|
||||
full_name=student.full_name,
|
||||
overall_average=overall_average,
|
||||
assessment_count=assessment_count,
|
||||
grades_by_assessment=grades_by_assessment,
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from api.dependencies import AsyncSessionDep
|
||||
from api.helpers import get_active_enrollment
|
||||
from infrastructure.database.models import (
|
||||
Student,
|
||||
StudentEnrollment,
|
||||
@@ -62,12 +63,8 @@ async def get_students(
|
||||
students_list = []
|
||||
for student in students:
|
||||
# Trouver l'inscription active
|
||||
current_enrollment = None
|
||||
for enrollment in student.enrollments:
|
||||
if enrollment.departure_date is None:
|
||||
current_enrollment = enrollment
|
||||
break
|
||||
|
||||
current_enrollment = get_active_enrollment(student)
|
||||
|
||||
# Filtrer par classe si demandé
|
||||
if class_id and (not current_enrollment or current_enrollment.class_group_id != class_id):
|
||||
continue
|
||||
@@ -78,7 +75,7 @@ async def get_students(
|
||||
last_name=student.last_name,
|
||||
first_name=student.first_name,
|
||||
email=student.email,
|
||||
full_name=f"{student.first_name} {student.last_name}",
|
||||
full_name=student.full_name,
|
||||
current_class_id=current_enrollment.class_group_id if current_enrollment else None,
|
||||
current_class_name=current_enrollment.class_group.name if current_enrollment else None
|
||||
)
|
||||
@@ -113,13 +110,10 @@ async def get_student(
|
||||
raise HTTPException(status_code=404, detail="Étudiant non trouvé")
|
||||
|
||||
# Trouver l'inscription active
|
||||
current_enrollment = None
|
||||
current_enrollment = get_active_enrollment(student)
|
||||
enrollments_list = []
|
||||
|
||||
|
||||
for enrollment in student.enrollments:
|
||||
if enrollment.departure_date is None:
|
||||
current_enrollment = enrollment
|
||||
|
||||
enrollments_list.append(
|
||||
EnrollmentRead(
|
||||
id=enrollment.id,
|
||||
@@ -144,7 +138,7 @@ async def get_student(
|
||||
last_name=student.last_name,
|
||||
first_name=student.first_name,
|
||||
email=student.email,
|
||||
full_name=f"{student.first_name} {student.last_name}",
|
||||
full_name=student.full_name,
|
||||
current_class_id=current_enrollment.class_group_id if current_enrollment else None,
|
||||
current_class_name=current_enrollment.class_group.name if current_enrollment else None,
|
||||
enrollments=enrollments_list
|
||||
@@ -210,7 +204,7 @@ async def create_student(
|
||||
last_name=new_student.last_name,
|
||||
first_name=new_student.first_name,
|
||||
email=new_student.email,
|
||||
full_name=f"{new_student.first_name} {new_student.last_name}",
|
||||
full_name=new_student.full_name,
|
||||
current_class_id=current_class_id,
|
||||
current_class_name=current_class_name
|
||||
)
|
||||
@@ -264,18 +258,14 @@ async def update_student(
|
||||
await session.refresh(student)
|
||||
|
||||
# Trouver la classe actuelle
|
||||
current_enrollment = None
|
||||
for enrollment in student.enrollments:
|
||||
if enrollment.departure_date is None:
|
||||
current_enrollment = enrollment
|
||||
break
|
||||
|
||||
current_enrollment = get_active_enrollment(student)
|
||||
|
||||
return StudentWithClass(
|
||||
id=student.id,
|
||||
last_name=student.last_name,
|
||||
first_name=student.first_name,
|
||||
email=student.email,
|
||||
full_name=f"{student.first_name} {student.last_name}",
|
||||
full_name=student.full_name,
|
||||
current_class_id=current_enrollment.class_group_id if current_enrollment else None,
|
||||
current_class_name=current_enrollment.class_group.name if current_enrollment else None
|
||||
)
|
||||
@@ -325,18 +315,14 @@ async def update_student_email(
|
||||
await session.refresh(student)
|
||||
|
||||
# Trouver la classe actuelle
|
||||
current_enrollment = None
|
||||
for enrollment in student.enrollments:
|
||||
if enrollment.departure_date is None:
|
||||
current_enrollment = enrollment
|
||||
break
|
||||
|
||||
current_enrollment = get_active_enrollment(student)
|
||||
|
||||
return StudentWithClass(
|
||||
id=student.id,
|
||||
last_name=student.last_name,
|
||||
first_name=student.first_name,
|
||||
email=student.email,
|
||||
full_name=f"{student.first_name} {student.last_name}",
|
||||
full_name=student.full_name,
|
||||
current_class_id=current_enrollment.class_group_id if current_enrollment else None,
|
||||
current_class_name=current_enrollment.class_group.name if current_enrollment else None
|
||||
)
|
||||
@@ -403,7 +389,7 @@ async def enroll_student(
|
||||
if active_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"L'élève {student.first_name} {student.last_name} est déjà inscrit dans une classe"
|
||||
detail=f"L'élève {student.full_name} est déjà inscrit dans une classe"
|
||||
)
|
||||
else:
|
||||
# Nouvel élève
|
||||
@@ -447,9 +433,9 @@ async def enroll_student(
|
||||
return EnrollmentResponse(
|
||||
enrollment_id=enrollment.id,
|
||||
student_id=student.id,
|
||||
student_name=f"{student.first_name} {student.last_name}",
|
||||
student_name=student.full_name,
|
||||
class_name=class_group.name,
|
||||
message=f"Élève {student.first_name} {student.last_name} inscrit en {class_group.name}",
|
||||
message=f"Élève {student.full_name} inscrit en {class_group.name}",
|
||||
is_new_student=is_new_student
|
||||
)
|
||||
|
||||
@@ -493,9 +479,9 @@ async def transfer_student(
|
||||
if not old_enrollment:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Aucune inscription active trouvée pour l'élève {student.first_name} {student.last_name}"
|
||||
detail=f"Aucune inscription active trouvée pour l'élève {student.full_name}"
|
||||
)
|
||||
|
||||
|
||||
old_class_name = old_enrollment.class_group.name
|
||||
|
||||
# Terminer l'ancienne inscription
|
||||
@@ -516,10 +502,10 @@ async def transfer_student(
|
||||
return TransferResponse(
|
||||
old_enrollment_id=old_enrollment.id,
|
||||
new_enrollment_id=new_enrollment.id,
|
||||
student_name=f"{student.first_name} {student.last_name}",
|
||||
student_name=student.full_name,
|
||||
old_class_name=old_class_name,
|
||||
new_class_name=new_class.name,
|
||||
message=f"Élève {student.first_name} {student.last_name} transféré de {old_class_name} vers {new_class.name}"
|
||||
message=f"Élève {student.full_name} transféré de {old_class_name} vers {new_class.name}"
|
||||
)
|
||||
|
||||
|
||||
@@ -554,9 +540,9 @@ async def record_departure(
|
||||
if not enrollment:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Aucune inscription active trouvée pour l'élève {student.first_name} {student.last_name}"
|
||||
detail=f"Aucune inscription active trouvée pour l'élève {student.full_name}"
|
||||
)
|
||||
|
||||
|
||||
class_name = enrollment.class_group.name
|
||||
|
||||
# Enregistrer le départ
|
||||
@@ -567,7 +553,7 @@ async def record_departure(
|
||||
|
||||
return DepartureResponse(
|
||||
enrollment_id=enrollment.id,
|
||||
student_name=f"{student.first_name} {student.last_name}",
|
||||
student_name=student.full_name,
|
||||
class_name=class_name,
|
||||
message=f"Départ de {student.first_name} {student.last_name} de {class_name} enregistré"
|
||||
message=f"Départ de {student.full_name} de {class_name} enregistré"
|
||||
)
|
||||
|
||||
@@ -123,6 +123,16 @@ class Student(Base):
|
||||
"CouncilAppreciation", back_populates="student", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
"""Prénom Nom"""
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
@property
|
||||
def full_name_reversed(self) -> str:
|
||||
"""Nom Prénom"""
|
||||
return f"{self.last_name} {self.first_name}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Student {self.first_name} {self.last_name}>"
|
||||
|
||||
|
||||
19
frontend/package-lock.json
generated
19
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "notytex-frontend",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"@sgratzl/chartjs-chart-boxplot": "^4.4.5",
|
||||
"axios": "^1.7.9",
|
||||
"chart.js": "^4.4.7",
|
||||
"pinia": "^2.2.6",
|
||||
@@ -1143,6 +1144,24 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@sgratzl/boxplots": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sgratzl/boxplots/-/boxplots-2.0.0.tgz",
|
||||
"integrity": "sha512-XHQTNTk0OtDEZyT7v+BKNKt4OEXYhn4GtfI+iQ1eFiFsx8Aq/csz+mOJYbfpkiP8Popd/8Nky8vLh1zzkYn0gw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sgratzl/chartjs-chart-boxplot": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@sgratzl/chartjs-chart-boxplot/-/chartjs-chart-boxplot-4.4.5.tgz",
|
||||
"integrity": "sha512-hvHcUIPIyzNPRmvPNIbA/R68zZKv7XHUcNrYJZTB8QsVE0KKuuGuEK98yCeCy32vZXQBl/PrHGOYMXeuqZrU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sgratzl/boxplots": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chart.js": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
||||
@@ -10,20 +10,21 @@
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"pinia": "^2.2.6",
|
||||
"@sgratzl/chartjs-chart-boxplot": "^4.4.5",
|
||||
"axios": "^1.7.9",
|
||||
"chart.js": "^4.4.7",
|
||||
"vue-chartjs": "^5.3.2"
|
||||
"pinia": "^2.2.6",
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"postcss": "^8.4.49",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-plugin-vue": "^9.31.0"
|
||||
"eslint-plugin-vue": "^9.31.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<div class="h-screen flex flex-col overflow-hidden">
|
||||
<AppHeader />
|
||||
<main class="flex-1">
|
||||
<main class="flex-1 overflow-auto">
|
||||
<router-view />
|
||||
</main>
|
||||
<AppFooter />
|
||||
<NotificationContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppHeader from '@/components/common/AppHeader.vue'
|
||||
import AppFooter from '@/components/common/AppFooter.vue'
|
||||
import NotificationContainer from '@/components/common/NotificationContainer.vue'
|
||||
</script>
|
||||
|
||||
@@ -106,6 +106,14 @@
|
||||
.table tbody tr {
|
||||
@apply hover:bg-gray-50 transition-colors;
|
||||
}
|
||||
|
||||
.btn-modal-cancel {
|
||||
@apply px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50;
|
||||
}
|
||||
|
||||
.btn-modal-confirm {
|
||||
@apply px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Progress indicator colors */
|
||||
|
||||
@@ -36,14 +36,14 @@
|
||||
<!-- Create new option -->
|
||||
<div
|
||||
v-if="canCreate"
|
||||
class="px-3 py-2 text-sm cursor-pointer hover:bg-purple-100 border-t border-gray-200 flex items-center"
|
||||
:class="{ 'bg-purple-100': selectedIndex === suggestions.length }"
|
||||
class="px-3 py-2 text-sm cursor-pointer hover:bg-accent-100 border-t border-gray-200 flex items-center"
|
||||
:class="{ 'bg-accent-100': selectedIndex === suggestions.length }"
|
||||
@mousedown.prevent="openCreateModal"
|
||||
>
|
||||
<svg class="w-4 h-4 text-purple-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg class="w-4 h-4 text-accent-600 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>
|
||||
<span class="text-purple-600 font-medium">Créer "{{ searchQuery }}"</span>
|
||||
<span class="text-accent-600 font-medium">Créer "{{ searchQuery }}"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
:key="color"
|
||||
type="button"
|
||||
class="w-8 h-8 rounded-full border-2 transition-all"
|
||||
:class="newDomain.color === color ? 'ring-2 ring-purple-500 border-white' : 'border-gray-300 hover:border-gray-400'"
|
||||
:class="newDomain.color === color ? 'ring-2 ring-accent-500 border-white' : 'border-gray-300 hover:border-gray-400'"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="newDomain.color = color"
|
||||
></button>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center text-sm text-gray-500">
|
||||
<p>© {{ currentYear }} Notytex - Gestion Scolaire</p>
|
||||
<p class="mt-2 sm:mt-0">Version 2.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const currentYear = new Date().getFullYear()
|
||||
</script>
|
||||
@@ -66,15 +66,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
// Simple icon components
|
||||
const HomeIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /></svg>' }
|
||||
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
|
||||
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
|
||||
const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
|
||||
const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
|
||||
const MenuIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>' }
|
||||
const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
|
||||
import { HomeIcon, UsersIcon, ClipboardIcon, AcademicCapIcon, CogIcon, MenuIcon, XIcon } from '@/components/icons'
|
||||
|
||||
const route = useRoute()
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
23
frontend/src/components/common/Breadcrumb.vue
Normal file
23
frontend/src/components/common/Breadcrumb.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<nav class="flex items-center text-sm text-gray-500 mb-1" aria-label="Breadcrumb">
|
||||
<template v-for="(crumb, index) in crumbs" :key="index">
|
||||
<router-link v-if="crumb.to" :to="crumb.to" class="hover:text-gray-700 transition-colors">
|
||||
{{ crumb.label }}
|
||||
</router-link>
|
||||
<span v-else class="text-gray-900 font-medium">{{ crumb.label }}</span>
|
||||
<svg v-if="index < crumbs.length - 1" class="w-4 h-4 mx-1.5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
crumbs: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: (v) => v.every(c => c.label)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
73
frontend/src/components/common/ConfirmDialog.vue
Normal file
73
frontend/src/components/common/ConfirmDialog.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<Modal v-model="visible" :title="title" size="sm">
|
||||
<p class="text-gray-600">{{ message }}</p>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="cancel" class="btn-modal-cancel">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="confirm"
|
||||
class="px-4 py-2 text-sm font-medium text-white rounded-md"
|
||||
:class="confirmClasses"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Modal from './Modal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Confirmer'
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: 'Êtes-vous sûr ?'
|
||||
},
|
||||
confirmLabel: {
|
||||
type: String,
|
||||
default: 'Confirmer'
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'danger',
|
||||
validator: (v) => ['danger', 'primary', 'warning'].includes(v)
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const confirmClasses = computed(() => {
|
||||
const map = {
|
||||
danger: 'bg-danger-600 hover:bg-danger-700',
|
||||
primary: 'bg-primary-600 hover:bg-primary-700',
|
||||
warning: 'bg-warning-600 hover:bg-warning-700'
|
||||
}
|
||||
return map[props.variant]
|
||||
})
|
||||
|
||||
function cancel() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { FolderIcon, UsersIcon, ClipboardIcon, ChartIcon, SearchIcon } from '@/components/icons'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
@@ -58,13 +59,6 @@ const props = defineProps({
|
||||
|
||||
defineEmits(['secondary'])
|
||||
|
||||
// Icons components
|
||||
const FolderIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /></svg>' }
|
||||
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
|
||||
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
|
||||
const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
|
||||
const SearchIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>' }
|
||||
|
||||
const icons = {
|
||||
folder: FolderIcon,
|
||||
users: UsersIcon,
|
||||
|
||||
@@ -49,8 +49,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
|
||||
import { XIcon } from '@/components/icons'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
||||
@@ -29,11 +29,7 @@ const notificationsStore = useNotificationsStore()
|
||||
const { notifications } = storeToRefs(notificationsStore)
|
||||
const { remove, error } = notificationsStore
|
||||
|
||||
// Icon components
|
||||
const CheckIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>' }
|
||||
const ExclamationIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" /></svg>' }
|
||||
const InfoIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>' }
|
||||
const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
|
||||
import { CheckIcon, ExclamationIcon, InfoIcon, XIcon } from '@/components/icons'
|
||||
|
||||
function notificationClasses(type) {
|
||||
const classes = {
|
||||
|
||||
47
frontend/src/components/common/PageHeader.vue
Normal file
47
frontend/src/components/common/PageHeader.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<Breadcrumb v-if="breadcrumbs && breadcrumbs.length" :crumbs="breadcrumbs" />
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<router-link
|
||||
v-if="backTo"
|
||||
:to="backTo"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
|
||||
</router-link>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ title }}</h1>
|
||||
<slot name="meta"></slot>
|
||||
</div>
|
||||
<p v-if="subtitle" class="text-sm text-gray-500 mt-1">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Breadcrumb from '@/components/common/Breadcrumb.vue'
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
backTo: {
|
||||
type: [String, Object],
|
||||
default: null
|
||||
},
|
||||
breadcrumbs: {
|
||||
type: Array,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
62
frontend/src/components/common/TrimesterSelector.vue
Normal file
62
frontend/src/components/common/TrimesterSelector.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="flex gap-1 items-center">
|
||||
<button
|
||||
v-if="showAll"
|
||||
@click="$emit('update:modelValue', null)"
|
||||
class="px-2.5 py-1 rounded text-xs font-medium transition-colors"
|
||||
:class="[
|
||||
sizeClasses,
|
||||
modelValue === null
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
]"
|
||||
>
|
||||
{{ allLabel }}
|
||||
</button>
|
||||
<div v-if="showAll" class="border-l border-gray-300 h-5 mx-1"></div>
|
||||
<button
|
||||
v-for="t in [1, 2, 3]"
|
||||
:key="t"
|
||||
@click="$emit('update:modelValue', t)"
|
||||
class="px-2.5 py-1 rounded text-xs font-medium transition-colors"
|
||||
:class="[
|
||||
sizeClasses,
|
||||
modelValue === t
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
]"
|
||||
>
|
||||
T{{ t }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
showAll: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allLabel: {
|
||||
type: String,
|
||||
default: 'Tous'
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'sm',
|
||||
validator: (v) => ['sm', 'md'].includes(v)
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
return props.size === 'md' ? 'px-3 py-1.5 text-sm' : 'px-2.5 py-1 text-xs'
|
||||
})
|
||||
</script>
|
||||
@@ -55,7 +55,7 @@
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded border-2 border-white shadow-sm flex items-center justify-center text-xs font-bold"
|
||||
:style="{ backgroundColor: example.color, color: getTextColor(example.color) }"
|
||||
:style="{ backgroundColor: example.color, color: getTextColorForBg(example.color) }"
|
||||
>
|
||||
{{ example.note }}
|
||||
</div>
|
||||
@@ -257,9 +257,17 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between items-center">
|
||||
<button @click="resetScale" class="btn btn-secondary">
|
||||
<button @click="showResetConfirm = true" class="btn btn-secondary">
|
||||
Valeurs par defaut
|
||||
</button>
|
||||
<ConfirmDialog
|
||||
v-model="showResetConfirm"
|
||||
title="Reinitialiser l'echelle"
|
||||
message="Reinitialiser l'echelle aux valeurs par defaut ?"
|
||||
confirmLabel="Reinitialiser"
|
||||
variant="warning"
|
||||
@confirm="doResetScale"
|
||||
/>
|
||||
|
||||
<button
|
||||
@click="saveScale"
|
||||
@@ -335,6 +343,7 @@ import { useConfigStore } from '@/stores/config'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import ColorPicker from '@/components/common/ColorPicker.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const notifications = useNotificationsStore()
|
||||
@@ -387,86 +396,7 @@ const gradientExamples = computed(() => [
|
||||
{ percent: 0.75, note: 15, color: interpolateColorHSL(gradientForm.value.min_color, gradientForm.value.max_color, 0.75) }
|
||||
])
|
||||
|
||||
// Color functions
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : { r: 0, g: 0, b: 0 }
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
let h, s, l = (max + min) / 2
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
return { h: h * 360, s: s * 100, l: l * 100 }
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
h /= 360; s /= 100; l /= 100
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1/6) return p + (q - p) * 6 * t
|
||||
if (t < 1/2) return q
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
||||
return p
|
||||
}
|
||||
let r, g, b
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1/3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1/3)
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
|
||||
}
|
||||
|
||||
function interpolateColorHSL(color1, color2, factor) {
|
||||
const rgb1 = hexToRgb(color1)
|
||||
const rgb2 = hexToRgb(color2)
|
||||
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b)
|
||||
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b)
|
||||
|
||||
let deltaH = hsl2.h - hsl1.h
|
||||
if (deltaH > 180) hsl2.h -= 360
|
||||
else if (deltaH < -180) hsl2.h += 360
|
||||
|
||||
const h = ((hsl1.h + (hsl2.h - hsl1.h) * factor) % 360 + 360) % 360
|
||||
const s = hsl1.s + (hsl2.s - hsl1.s) * factor
|
||||
const l = hsl1.l + (hsl2.l - hsl1.l) * factor
|
||||
|
||||
const rgb = hslToRgb(h, s, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
function getTextColor(bgColor) {
|
||||
const rgb = hexToRgb(bgColor)
|
||||
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
|
||||
return brightness > 128 ? '#000000' : '#ffffff'
|
||||
}
|
||||
import { interpolateColorHSL, getTextColorForBg } from '@/utils/colors'
|
||||
|
||||
// Actions
|
||||
function updateGradientPreview() {
|
||||
@@ -504,9 +434,9 @@ async function saveScale() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetScale() {
|
||||
if (!confirm('Reinitialiser l\'echelle aux valeurs par defaut ?')) return
|
||||
|
||||
const showResetConfirm = ref(false)
|
||||
|
||||
async function doResetScale() {
|
||||
try {
|
||||
await configStore.resetScale()
|
||||
loadScaleForm()
|
||||
|
||||
473
frontend/src/components/council/CouncilStudentDetail.vue
Normal file
473
frontend/src/components/council/CouncilStudentDetail.vue
Normal file
@@ -0,0 +1,473 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden h-full flex flex-col">
|
||||
<!-- Compact header with inline navigation -->
|
||||
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50 flex items-center gap-3 flex-shrink-0">
|
||||
<button
|
||||
@click="$emit('prev')"
|
||||
:disabled="!hasPrev"
|
||||
class="p-1 rounded hover:bg-gray-200 text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
<div class="flex-1 flex items-center justify-between min-w-0">
|
||||
<div class="min-w-0">
|
||||
<span class="font-bold text-gray-900">{{ student.last_name }} {{ student.first_name }}</span>
|
||||
<span class="text-xs text-gray-500 ml-2">
|
||||
#{{ student.rank }}/{{ totalStudents }}
|
||||
</span>
|
||||
<span v-if="gapToMean !== null" class="text-xs ml-1" :class="gapToMean >= 0 ? 'text-green-600' : 'text-red-500'">
|
||||
({{ gapToMean >= 0 ? '+' : '' }}{{ gapToMean.toFixed(1) }})
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1 flex-shrink-0 ml-3">
|
||||
<span class="text-2xl font-bold tabular-nums" :class="averageColor(student.average)">
|
||||
{{ student.average !== null ? student.average.toFixed(2) : '-' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">/20</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('next')"
|
||||
:disabled="!hasNext"
|
||||
class="p-1 rounded hover:bg-gray-200 text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed flex-shrink-0"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content: 2-column grid on xl -->
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-5 gap-3">
|
||||
<!-- Left column: chart + trimester summary -->
|
||||
<div class="xl:col-span-3 space-y-2">
|
||||
<!-- Chart -->
|
||||
<section v-if="chartData.labels.length > 0">
|
||||
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Parcours sur l'année</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-2" style="height: 220px">
|
||||
<Bar :key="student.student_id" :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
<!-- Trimester summary: inline compact -->
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div
|
||||
v-for="tri in trimesterSummary"
|
||||
:key="tri.trimester"
|
||||
class="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded text-center"
|
||||
:class="tri.trimester === currentTrimester ? 'bg-blue-50 ring-1 ring-blue-200' : 'bg-gray-50'"
|
||||
>
|
||||
<span class="text-[10px] text-gray-400">T{{ tri.trimester }}</span>
|
||||
<span class="text-sm font-bold tabular-nums" :class="averageColor(tri.studentAvg)">
|
||||
{{ tri.studentAvg !== null ? tri.studentAvg.toFixed(1) : '-' }}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400">(cls {{ tri.classAvg !== null ? tri.classAvg.toFixed(1) : '-' }})</span>
|
||||
<span v-if="tri.rank" class="text-[10px] text-gray-400">#{{ tri.rank }}</span>
|
||||
<span v-if="tri.delta !== null" class="text-[10px]" :class="tri.delta > 0 ? 'text-green-600' : tri.delta < 0 ? 'text-red-500' : 'text-gray-400'">
|
||||
{{ tri.delta > 0 ? '+' : '' }}{{ tri.delta.toFixed(1) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right column: evaluations + domains + competences -->
|
||||
<div class="xl:col-span-2 space-y-3">
|
||||
<!-- Evaluations -->
|
||||
<section v-if="assessmentList.length">
|
||||
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Évaluations</h3>
|
||||
<div class="space-y-0.5">
|
||||
<div
|
||||
v-for="a in assessmentList"
|
||||
:key="a.assessment_id"
|
||||
class="flex items-center justify-between py-1 px-2 rounded text-xs"
|
||||
:class="assessmentBg(a)"
|
||||
>
|
||||
<span class="text-gray-700 truncate mr-1">{{ a.assessment_title }}</span>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<span class="font-semibold tabular-nums" :class="averageColor(a.score_on_20)">
|
||||
{{ a.score !== null ? `${a.score.toFixed(1)}/${a.max_points.toFixed(0)}` : '-' }}
|
||||
</span>
|
||||
<span v-if="a.score_on_20 !== null" class="text-[10px] text-gray-400 tabular-nums">
|
||||
({{ a.score_on_20.toFixed(1) }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Box plots section - full width below chart+evals grid -->
|
||||
<div v-if="domainList.length || competenceList.length"
|
||||
class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-3">
|
||||
<section v-if="domainList.length">
|
||||
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Domaines</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-2" :style="{ height: domainChartHeight }">
|
||||
<ChartGeneric type="boxplot" :data="domainBoxPlotData" :options="boxPlotOptions" />
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="competenceList.length">
|
||||
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Compétences</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-2" :style="{ height: competenceChartHeight }">
|
||||
<ChartGeneric type="boxplot" :data="competenceBoxPlotData" :options="boxPlotOptions" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Bar, Chart as ChartGeneric } from 'vue-chartjs'
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, LineElement, PointElement, ScatterController, Title, Tooltip, Legend } from 'chart.js'
|
||||
import { BoxPlotController, BoxAndWiskers } from '@sgratzl/chartjs-chart-boxplot'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, ScatterController, Title, Tooltip, Legend, BoxPlotController, BoxAndWiskers)
|
||||
|
||||
const props = defineProps({
|
||||
student: { type: Object, required: true },
|
||||
classStats: { type: Object, required: true },
|
||||
allTrimesterStats: { type: Array, default: () => [] },
|
||||
currentTrimester: { type: Number, required: true },
|
||||
totalStudents: { type: Number, required: true },
|
||||
hasPrev: { type: Boolean, default: false },
|
||||
hasNext: { type: Boolean, default: false },
|
||||
classDomainsStats: { type: Array, default: () => [] },
|
||||
classCompetencesStats: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
defineEmits(['prev', 'next'])
|
||||
|
||||
const TRIMESTER_COLORS = {
|
||||
1: 'rgba(99, 102, 241, 0.7)',
|
||||
2: 'rgba(59, 130, 246, 0.7)',
|
||||
3: 'rgba(139, 92, 246, 0.7)'
|
||||
}
|
||||
|
||||
const gapToMean = computed(() => {
|
||||
if (props.student.average === null || props.classStats.mean === null) return null
|
||||
return props.student.average - props.classStats.mean
|
||||
})
|
||||
|
||||
// Chart data
|
||||
const chartData = computed(() => {
|
||||
const labels = []
|
||||
const studentScores = []
|
||||
const classAvgs = []
|
||||
const barBgColors = []
|
||||
|
||||
for (const { trimester, stats } of props.allTrimesterStats) {
|
||||
const studentData = stats.student_averages?.find(s => s.student_id === props.student.student_id)
|
||||
const refStudent = stats.student_averages?.find(s => s.assessment_scores && Object.keys(s.assessment_scores).length > 0)
|
||||
if (!refStudent) continue
|
||||
|
||||
const assessments = Object.values(refStudent.assessment_scores)
|
||||
.sort((a, b) => a.assessment_id - b.assessment_id)
|
||||
|
||||
for (const a of assessments) {
|
||||
labels.push(`T${trimester}: ${a.assessment_title}`)
|
||||
studentScores.push(studentData?.assessment_scores?.[a.assessment_id]?.score_on_20 ?? null)
|
||||
barBgColors.push(TRIMESTER_COLORS[trimester] || TRIMESTER_COLORS[1])
|
||||
|
||||
let sum = 0, count = 0
|
||||
for (const s of stats.student_averages) {
|
||||
const score = s.assessment_scores?.[a.assessment_id]
|
||||
if (score?.score_on_20 != null) { sum += score.score_on_20; count++ }
|
||||
}
|
||||
classAvgs.push(count > 0 ? sum / count : null)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Élève',
|
||||
data: studentScores,
|
||||
backgroundColor: barBgColors,
|
||||
borderRadius: 3,
|
||||
borderWidth: 0,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
label: 'Moyenne classe',
|
||||
data: classAvgs,
|
||||
type: 'line',
|
||||
borderColor: 'rgba(249, 115, 22, 0.8)',
|
||||
pointBackgroundColor: 'rgba(249, 115, 22, 0.8)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 3,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
order: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { boxWidth: 10, font: { size: 10 }, padding: 12 }
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label(ctx) {
|
||||
if (ctx.raw === null) return null
|
||||
return `${ctx.dataset.label}: ${ctx.raw.toFixed(1)}/20`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 20,
|
||||
ticks: { stepSize: 5, font: { size: 9 } }
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
callback(value) {
|
||||
const label = this.getLabelForValue(value)
|
||||
return label.length > 18 ? label.slice(0, 16) + '...' : label
|
||||
},
|
||||
maxRotation: 35,
|
||||
font: { size: 8 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trimester summary
|
||||
const trimesterSummary = computed(() => {
|
||||
const summaries = []
|
||||
let prevStudentAvg = null
|
||||
|
||||
for (const { trimester, stats } of props.allTrimesterStats) {
|
||||
const studentData = stats.student_averages?.find(s => s.student_id === props.student.student_id)
|
||||
const studentAvg = studentData?.average ?? null
|
||||
|
||||
let rank = null
|
||||
if (studentAvg !== null) {
|
||||
const withAvg = (stats.student_averages || []).filter(s => s.average !== null)
|
||||
withAvg.sort((a, b) => b.average - a.average)
|
||||
const idx = withAvg.findIndex(s => s.student_id === props.student.student_id)
|
||||
if (idx >= 0) rank = idx + 1
|
||||
}
|
||||
|
||||
const delta = (studentAvg !== null && prevStudentAvg !== null) ? studentAvg - prevStudentAvg : null
|
||||
summaries.push({ trimester, studentAvg, classAvg: stats.mean ?? null, rank, delta })
|
||||
prevStudentAvg = studentAvg
|
||||
}
|
||||
|
||||
return summaries
|
||||
})
|
||||
|
||||
// Assessment list (current trimester only)
|
||||
const assessmentList = computed(() => {
|
||||
if (!props.student.assessment_scores) return []
|
||||
return Object.values(props.student.assessment_scores).sort((a, b) => a.assessment_id - b.assessment_id)
|
||||
})
|
||||
|
||||
// Domain stats
|
||||
const domainList = computed(() => {
|
||||
if (!props.student.domain_stats) return []
|
||||
const lookup = {}
|
||||
for (const d of props.classDomainsStats) { lookup[d.id] = d }
|
||||
return Object.values(props.student.domain_stats)
|
||||
.filter(d => d.total_points_possible > 0)
|
||||
.map(d => {
|
||||
const meta = lookup[d.domain_id] || {}
|
||||
return {
|
||||
id: d.domain_id,
|
||||
name: meta.name || `Domaine ${d.domain_id}`,
|
||||
color: meta.color || '#6B7280',
|
||||
pct: (d.total_points_obtained / d.total_points_possible) * 100
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||
})
|
||||
|
||||
// Competence stats
|
||||
const competenceList = computed(() => {
|
||||
if (!props.student.competence_stats) return []
|
||||
const lookup = {}
|
||||
for (const c of props.classCompetencesStats) { lookup[c.id] = c }
|
||||
return Object.values(props.student.competence_stats)
|
||||
.filter(c => c.total_points_possible > 0)
|
||||
.map(c => {
|
||||
const meta = lookup[c.competence_id] || {}
|
||||
return {
|
||||
id: c.competence_id,
|
||||
name: meta.name || `Compétence ${c.competence_id}`,
|
||||
color: meta.color || '#6B7280',
|
||||
pct: (c.total_points_obtained / c.total_points_possible) * 100
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||
})
|
||||
|
||||
// Domain distributions from all students in the class
|
||||
const domainDistributions = computed(() => {
|
||||
const distributions = {}
|
||||
for (const student of props.classStats.student_averages || []) {
|
||||
for (const [domainId, stats] of Object.entries(student.domain_stats || {})) {
|
||||
if (stats.total_points_possible <= 0) continue
|
||||
if (!distributions[domainId]) distributions[domainId] = []
|
||||
distributions[domainId].push(
|
||||
(stats.total_points_obtained / stats.total_points_possible) * 100
|
||||
)
|
||||
}
|
||||
}
|
||||
return distributions
|
||||
})
|
||||
|
||||
// Competence distributions from all students in the class
|
||||
const competenceDistributions = computed(() => {
|
||||
const distributions = {}
|
||||
for (const student of props.classStats.student_averages || []) {
|
||||
for (const [compId, stats] of Object.entries(student.competence_stats || {})) {
|
||||
if (stats.total_points_possible <= 0) continue
|
||||
if (!distributions[compId]) distributions[compId] = []
|
||||
distributions[compId].push(
|
||||
(stats.total_points_obtained / stats.total_points_possible) * 100
|
||||
)
|
||||
}
|
||||
}
|
||||
return distributions
|
||||
})
|
||||
|
||||
// Box plot data for domains
|
||||
const domainBoxPlotData = computed(() => {
|
||||
const domains = domainList.value
|
||||
return {
|
||||
labels: domains.map(d => d.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Classe',
|
||||
data: domains.map(d => domainDistributions.value[d.id] || []),
|
||||
backgroundColor: domains.map(d => `${d.color}33`),
|
||||
borderColor: domains.map(d => d.color),
|
||||
borderWidth: 1,
|
||||
outlierRadius: 2,
|
||||
itemRadius: 0,
|
||||
meanRadius: 0
|
||||
},
|
||||
{
|
||||
type: 'scatter',
|
||||
label: 'Élève',
|
||||
data: domains.map((d, i) => ({ x: d.pct, y: i })),
|
||||
pointRadius: 7,
|
||||
pointStyle: 'rectRot',
|
||||
pointBackgroundColor: 'rgba(239, 68, 68, 0.9)',
|
||||
pointBorderColor: 'white',
|
||||
pointBorderWidth: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Box plot data for competences
|
||||
const competenceBoxPlotData = computed(() => {
|
||||
const comps = competenceList.value
|
||||
return {
|
||||
labels: comps.map(c => c.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Classe',
|
||||
data: comps.map(c => competenceDistributions.value[c.id] || []),
|
||||
backgroundColor: comps.map(c => `${c.color}33`),
|
||||
borderColor: comps.map(c => c.color),
|
||||
borderWidth: 1,
|
||||
outlierRadius: 2,
|
||||
itemRadius: 0,
|
||||
meanRadius: 0
|
||||
},
|
||||
{
|
||||
type: 'scatter',
|
||||
label: 'Élève',
|
||||
data: comps.map((c, i) => ({ x: c.pct, y: i })),
|
||||
pointRadius: 7,
|
||||
pointStyle: 'rectRot',
|
||||
pointBackgroundColor: 'rgba(239, 68, 68, 0.9)',
|
||||
pointBorderColor: 'white',
|
||||
pointBorderWidth: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const boxPlotOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label(ctx) {
|
||||
if (ctx.dataset.type === 'scatter') {
|
||||
return `Élève : ${ctx.raw.x.toFixed(0)}%`
|
||||
}
|
||||
const item = ctx.raw
|
||||
return [
|
||||
`Min: ${item.min?.toFixed(0)}%`,
|
||||
`Q1: ${item.q1?.toFixed(0)}%`,
|
||||
`Med: ${item.median?.toFixed(0)}%`,
|
||||
`Q3: ${item.q3?.toFixed(0)}%`,
|
||||
`Max: ${item.max?.toFixed(0)}%`
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: { callback: v => `${v}%`, font: { size: 9 } }
|
||||
},
|
||||
y: {
|
||||
ticks: { font: { size: 10 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const domainChartHeight = computed(() =>
|
||||
`${Math.max(100, domainList.value.length * 35 + 40)}px`
|
||||
)
|
||||
|
||||
const competenceChartHeight = computed(() =>
|
||||
`${Math.max(100, competenceList.value.length * 35 + 40)}px`
|
||||
)
|
||||
|
||||
function assessmentBg(a) {
|
||||
if (a.score_on_20 === null) return 'bg-gray-50'
|
||||
const classAvg = getClassAssessmentAvg(a.assessment_id)
|
||||
if (classAvg === null) return 'bg-gray-50'
|
||||
if (a.score_on_20 >= classAvg + 0.5) return 'bg-green-50'
|
||||
if (a.score_on_20 <= classAvg - 0.5) return 'bg-red-50'
|
||||
return 'bg-gray-50'
|
||||
}
|
||||
|
||||
function getClassAssessmentAvg(assessmentId) {
|
||||
const students = props.classStats.student_averages
|
||||
if (!students?.length) return null
|
||||
let sum = 0, count = 0
|
||||
for (const s of students) {
|
||||
const score = s.assessment_scores?.[assessmentId]
|
||||
if (score?.score_on_20 != null) { sum += score.score_on_20; count++ }
|
||||
}
|
||||
return count > 0 ? sum / count : null
|
||||
}
|
||||
|
||||
function averageColor(avg) {
|
||||
if (avg === null || avg === undefined) return 'text-gray-400'
|
||||
if (avg < 10) return 'text-red-600'
|
||||
if (avg >= 14) return 'text-green-600'
|
||||
return 'text-gray-900'
|
||||
}
|
||||
</script>
|
||||
74
frontend/src/components/council/CouncilStudentList.vue
Normal file
74
frontend/src/components/council/CouncilStudentList.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden h-full flex flex-col">
|
||||
<div class="px-3 py-2 border-b border-gray-200 flex-shrink-0">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
:value="search"
|
||||
@input="$emit('update:search', $event.target.value)"
|
||||
placeholder="Rechercher..."
|
||||
class="w-full text-xs border border-gray-300 rounded py-1.5 pl-7 pr-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<svg class="absolute left-2 top-2 h-3.5 w-3.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<button
|
||||
v-for="student in students"
|
||||
:key="student.student_id"
|
||||
@click="$emit('select', student.student_id)"
|
||||
class="w-full text-left px-3 py-1.5 border-b border-gray-50 hover:bg-gray-50 transition-colors"
|
||||
:class="student.student_id === selectedStudentId ? 'bg-blue-50 border-l-2 border-l-blue-500' : 'border-l-2 border-l-transparent'"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<span class="text-xs font-medium text-gray-900 truncate">
|
||||
{{ student.last_name }} {{ student.first_name }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<span v-if="student.trend" class="text-[10px]" :class="trendClass(student.trend)">
|
||||
{{ trendIcon(student.trend) }}
|
||||
</span>
|
||||
<span class="text-xs font-bold tabular-nums" :class="averageColor(student.average)">
|
||||
{{ student.average !== null ? student.average.toFixed(1) : '-' }}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400 tabular-nums w-7 text-right">
|
||||
#{{ student.rank ?? '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
students: { type: Array, required: true },
|
||||
selectedStudentId: { type: Number, default: null },
|
||||
classMean: { type: Number, default: null },
|
||||
search: { type: String, default: '' }
|
||||
})
|
||||
|
||||
defineEmits(['select', 'update:search'])
|
||||
|
||||
function averageColor(avg) {
|
||||
if (avg === null) return 'text-gray-400'
|
||||
if (avg < 10) return 'text-red-600'
|
||||
if (avg >= 14) return 'text-green-600'
|
||||
return 'text-gray-900'
|
||||
}
|
||||
|
||||
function trendIcon(trend) {
|
||||
if (trend === 'up') return '\u2197'
|
||||
if (trend === 'down') return '\u2198'
|
||||
return '\u2192'
|
||||
}
|
||||
|
||||
function trendClass(trend) {
|
||||
if (trend === 'up') return 'text-green-600'
|
||||
if (trend === 'down') return 'text-red-500'
|
||||
return 'text-gray-400'
|
||||
}
|
||||
</script>
|
||||
33
frontend/src/components/icons/index.js
Normal file
33
frontend/src/components/icons/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
export const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
|
||||
|
||||
export const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
|
||||
|
||||
export const CheckIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>' }
|
||||
|
||||
export const ChevronRightIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>' }
|
||||
|
||||
export const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
|
||||
|
||||
export const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
|
||||
|
||||
export const ExclamationIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" /></svg>' }
|
||||
|
||||
export const FolderIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /></svg>' }
|
||||
|
||||
export const HomeIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /></svg>' }
|
||||
|
||||
export const InfoIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>' }
|
||||
|
||||
export const MenuIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>' }
|
||||
|
||||
export const PencilIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>' }
|
||||
|
||||
export const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
|
||||
|
||||
export const SearchIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>' }
|
||||
|
||||
export const TrashIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>' }
|
||||
|
||||
export const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
|
||||
|
||||
export const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
|
||||
@@ -13,24 +13,12 @@ const routes = [
|
||||
component: () => import('@/views/ClassListView.vue'),
|
||||
meta: { title: 'Classes' }
|
||||
},
|
||||
{
|
||||
path: '/classes/new',
|
||||
name: 'class-create',
|
||||
component: () => import('@/views/ClassFormView.vue'),
|
||||
meta: { title: 'Nouvelle classe' }
|
||||
},
|
||||
{
|
||||
path: '/classes/:id',
|
||||
name: 'class-dashboard',
|
||||
component: () => import('@/views/ClassDashboardView.vue'),
|
||||
meta: { title: 'Dashboard classe' }
|
||||
},
|
||||
{
|
||||
path: '/classes/:id/edit',
|
||||
name: 'class-edit',
|
||||
component: () => import('@/views/ClassFormView.vue'),
|
||||
meta: { title: 'Modifier classe' }
|
||||
},
|
||||
{
|
||||
path: '/classes/:id/students',
|
||||
name: 'class-students',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import assessmentsService from '@/services/assessments'
|
||||
import { withLoading } from './helpers'
|
||||
|
||||
export const useAssessmentsStore = defineStore('assessments', () => {
|
||||
// State
|
||||
@@ -10,7 +11,7 @@ export const useAssessmentsStore = defineStore('assessments', () => {
|
||||
const currentGrades = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
|
||||
// Filters
|
||||
const filters = ref({
|
||||
trimester: null,
|
||||
@@ -21,22 +22,22 @@ export const useAssessmentsStore = defineStore('assessments', () => {
|
||||
|
||||
// Getters
|
||||
const assessmentsCount = computed(() => assessments.value.length)
|
||||
|
||||
|
||||
const filteredAssessments = computed(() => {
|
||||
let result = [...assessments.value]
|
||||
|
||||
|
||||
if (filters.value.trimester) {
|
||||
result = result.filter(a => a.trimester === filters.value.trimester)
|
||||
}
|
||||
|
||||
|
||||
if (filters.value.class_id) {
|
||||
result = result.filter(a => a.class_group_id === filters.value.class_id)
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const incompleteAssessments = computed(() =>
|
||||
const incompleteAssessments = computed(() =>
|
||||
assessments.value.filter(a => {
|
||||
const progress = a.progress || a.grading_progress
|
||||
return progress?.percentage < 100
|
||||
@@ -44,130 +45,61 @@ export const useAssessmentsStore = defineStore('assessments', () => {
|
||||
)
|
||||
|
||||
// Actions
|
||||
async function fetchAssessments(customFilters = null) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const queryFilters = customFilters || filters.value
|
||||
assessments.value = await assessmentsService.getAll(queryFilters)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchAssessments = withLoading(loading, error, async (customFilters = null) => {
|
||||
const queryFilters = customFilters || filters.value
|
||||
assessments.value = await assessmentsService.getAll(queryFilters)
|
||||
})
|
||||
|
||||
async function fetchAssessment(id) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
currentAssessment.value = await assessmentsService.getById(id)
|
||||
return currentAssessment.value
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchAssessment = withLoading(loading, error, async (id) => {
|
||||
currentAssessment.value = await assessmentsService.getById(id)
|
||||
return currentAssessment.value
|
||||
})
|
||||
|
||||
async function fetchResults(id) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
currentResults.value = await assessmentsService.getResults(id)
|
||||
return currentResults.value
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchResults = withLoading(loading, error, async (id) => {
|
||||
currentResults.value = await assessmentsService.getResults(id)
|
||||
return currentResults.value
|
||||
})
|
||||
|
||||
async function fetchGrades(id) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
currentGrades.value = await assessmentsService.getGrades(id)
|
||||
return currentGrades.value
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchGrades = withLoading(loading, error, async (id) => {
|
||||
currentGrades.value = await assessmentsService.getGrades(id)
|
||||
return currentGrades.value
|
||||
})
|
||||
|
||||
async function createAssessment(data) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const newAssessment = await assessmentsService.create(data)
|
||||
assessments.value.unshift(newAssessment)
|
||||
return newAssessment
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const createAssessment = withLoading(loading, error, async (data) => {
|
||||
const newAssessment = await assessmentsService.create(data)
|
||||
assessments.value.unshift(newAssessment)
|
||||
return newAssessment
|
||||
})
|
||||
|
||||
async function updateAssessment(id, data) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const updated = await assessmentsService.update(id, data)
|
||||
const index = assessments.value.findIndex(a => a.id === id)
|
||||
if (index !== -1) {
|
||||
assessments.value[index] = updated
|
||||
}
|
||||
if (currentAssessment.value?.id === id) {
|
||||
currentAssessment.value = updated
|
||||
}
|
||||
return updated
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
const updateAssessment = withLoading(loading, error, async (id, data) => {
|
||||
const updated = await assessmentsService.update(id, data)
|
||||
const index = assessments.value.findIndex(a => a.id === id)
|
||||
if (index !== -1) {
|
||||
assessments.value[index] = updated
|
||||
}
|
||||
}
|
||||
if (currentAssessment.value?.id === id) {
|
||||
currentAssessment.value = updated
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
async function deleteAssessment(id) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await assessmentsService.delete(id)
|
||||
assessments.value = assessments.value.filter(a => a.id !== id)
|
||||
if (currentAssessment.value?.id === id) {
|
||||
currentAssessment.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
const deleteAssessment = withLoading(loading, error, async (id) => {
|
||||
await assessmentsService.delete(id)
|
||||
assessments.value = assessments.value.filter(a => a.id !== id)
|
||||
if (currentAssessment.value?.id === id) {
|
||||
currentAssessment.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function saveGrades(id, grades) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await assessmentsService.saveGrades(id, grades)
|
||||
// Refresh assessment to update progress
|
||||
await fetchAssessment(id)
|
||||
return result
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
// fetchAssessment is declared above so it can be called here safely.
|
||||
// withLoading sets loading=false in its finally block, so the inner call
|
||||
// to fetchAssessment runs as a nested operation within the outer wrapper.
|
||||
const saveGrades = withLoading(loading, error, async (id, grades) => {
|
||||
const result = await assessmentsService.saveGrades(id, grades)
|
||||
// Refresh assessment to update progress
|
||||
await fetchAssessment(id)
|
||||
return result
|
||||
})
|
||||
|
||||
function setFilters(newFilters) {
|
||||
filters.value = { ...filters.value, ...newFilters }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import classesService from '@/services/classes'
|
||||
import { withLoading } from './helpers'
|
||||
|
||||
export const useClassesStore = defineStore('classes', () => {
|
||||
// State
|
||||
@@ -12,104 +13,50 @@ export const useClassesStore = defineStore('classes', () => {
|
||||
|
||||
// Getters
|
||||
const classesCount = computed(() => classes.value.length)
|
||||
const totalStudents = computed(() =>
|
||||
const totalStudents = computed(() =>
|
||||
classes.value.reduce((sum, c) => sum + (c.students_count || c.student_count || 0), 0)
|
||||
)
|
||||
|
||||
// Actions
|
||||
async function fetchClasses() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
classes.value = await classesService.getAll()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchClasses = withLoading(loading, error, async () => {
|
||||
classes.value = await classesService.getAll()
|
||||
})
|
||||
|
||||
async function fetchClass(id) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
currentClass.value = await classesService.getById(id)
|
||||
return currentClass.value
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchClass = withLoading(loading, error, async (id) => {
|
||||
currentClass.value = await classesService.getById(id)
|
||||
return currentClass.value
|
||||
})
|
||||
|
||||
async function fetchClassStats(id, trimester = null) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
currentStats.value = await classesService.getStats(id, trimester)
|
||||
return currentStats.value
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchClassStats = withLoading(loading, error, async (id, trimester = null) => {
|
||||
currentStats.value = await classesService.getStats(id, trimester)
|
||||
return currentStats.value
|
||||
})
|
||||
|
||||
async function createClass(data) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const newClass = await classesService.create(data)
|
||||
classes.value.push(newClass)
|
||||
return newClass
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const createClass = withLoading(loading, error, async (data) => {
|
||||
const newClass = await classesService.create(data)
|
||||
classes.value.push(newClass)
|
||||
return newClass
|
||||
})
|
||||
|
||||
async function updateClass(id, data) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const updated = await classesService.update(id, data)
|
||||
const index = classes.value.findIndex(c => c.id === id)
|
||||
if (index !== -1) {
|
||||
classes.value[index] = updated
|
||||
}
|
||||
if (currentClass.value?.id === id) {
|
||||
currentClass.value = updated
|
||||
}
|
||||
return updated
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
const updateClass = withLoading(loading, error, async (id, data) => {
|
||||
const updated = await classesService.update(id, data)
|
||||
const index = classes.value.findIndex(c => c.id === id)
|
||||
if (index !== -1) {
|
||||
classes.value[index] = updated
|
||||
}
|
||||
}
|
||||
if (currentClass.value?.id === id) {
|
||||
currentClass.value = updated
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
async function deleteClass(id) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await classesService.delete(id)
|
||||
classes.value = classes.value.filter(c => c.id !== id)
|
||||
if (currentClass.value?.id === id) {
|
||||
currentClass.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
const deleteClass = withLoading(loading, error, async (id) => {
|
||||
await classesService.delete(id)
|
||||
classes.value = classes.value.filter(c => c.id !== id)
|
||||
if (currentClass.value?.id === id) {
|
||||
currentClass.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function clearCurrent() {
|
||||
currentClass.value = null
|
||||
|
||||
22
frontend/src/stores/helpers.js
Normal file
22
frontend/src/stores/helpers.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Wraps an async function with loading/error state management.
|
||||
*
|
||||
* @param {import('vue').Ref<boolean>} loading - shared loading ref
|
||||
* @param {import('vue').Ref<string|null>} error - shared error ref
|
||||
* @param {Function} fn - async function to wrap
|
||||
* @returns {Function} wrapped function with identical signature
|
||||
*/
|
||||
export function withLoading(loading, error, fn) {
|
||||
return async (...args) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return await fn(...args)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
79
frontend/src/utils/colors.js
Normal file
79
frontend/src/utils/colors.js
Normal file
@@ -0,0 +1,79 @@
|
||||
export function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : { r: 0, g: 0, b: 0 }
|
||||
}
|
||||
|
||||
export function rgbToHex(r, g, b) {
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
|
||||
}
|
||||
|
||||
export function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
let h, s, l = (max + min) / 2
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
return { h: h * 360, s: s * 100, l: l * 100 }
|
||||
}
|
||||
|
||||
export function hslToRgb(h, s, l) {
|
||||
h /= 360; s /= 100; l /= 100
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1/6) return p + (q - p) * 6 * t
|
||||
if (t < 1/2) return q
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
||||
return p
|
||||
}
|
||||
let r, g, b
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1/3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1/3)
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
|
||||
}
|
||||
|
||||
export function interpolateColorHSL(color1, color2, factor) {
|
||||
const rgb1 = hexToRgb(color1)
|
||||
const rgb2 = hexToRgb(color2)
|
||||
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b)
|
||||
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b)
|
||||
|
||||
let deltaH = hsl2.h - hsl1.h
|
||||
if (deltaH > 180) hsl2.h -= 360
|
||||
else if (deltaH < -180) hsl2.h += 360
|
||||
|
||||
const h = ((hsl1.h + (hsl2.h - hsl1.h) * factor) % 360 + 360) % 360
|
||||
const s = hsl1.s + (hsl2.s - hsl1.s) * factor
|
||||
const l = hsl1.l + (hsl2.l - hsl1.l) * factor
|
||||
|
||||
const rgb = hslToRgb(h, s, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
export function getTextColorForBg(bgColor) {
|
||||
const rgb = hexToRgb(bgColor)
|
||||
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
|
||||
return brightness > 128 ? '#000000' : '#ffffff'
|
||||
}
|
||||
@@ -4,53 +4,48 @@
|
||||
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
||||
|
||||
<template v-else-if="assessment">
|
||||
<!-- Hero section -->
|
||||
<div class="bg-gradient-to-r from-primary-600 to-indigo-700 rounded-2xl p-8 mb-8 text-white">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h1 class="text-3xl font-bold">{{ assessment.title }}</h1>
|
||||
<span class="bg-white/20 px-3 py-1 rounded-full text-sm">T{{ assessment.trimester }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-primary-100">
|
||||
<span>{{ assessment.class_name }}</span>
|
||||
<span>{{ formatDate(assessment.date) }}</span>
|
||||
<span>{{ assessment.total_points }} points</span>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
:title="assessment.title"
|
||||
:subtitle="`${assessment.class_name} \u00b7 ${formatDate(assessment.date)} \u00b7 ${assessment.total_points} points`"
|
||||
:breadcrumbs="[{ label: 'Évaluations', to: '/assessments' }, { label: assessment.title }]"
|
||||
>
|
||||
<template #meta>
|
||||
<span class="badge badge-primary">T{{ assessment.trimester }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<ProgressIndicator :progress="assessment.progress" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<router-link
|
||||
<router-link
|
||||
:to="`/assessments/${assessment.id}/grading`"
|
||||
class="card card-body text-center hover:shadow-md transition-shadow"
|
||||
class="card card-body text-center hover:shadow-md transition-shadow bg-primary-50 border-2 border-primary-200"
|
||||
>
|
||||
<PencilIcon class="w-8 h-8 text-primary-600 mx-auto mb-2" />
|
||||
<span class="font-medium">Noter</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
|
||||
<router-link
|
||||
:to="`/assessments/${assessment.id}/results`"
|
||||
class="card card-body text-center hover:shadow-md transition-shadow"
|
||||
>
|
||||
<ChartIcon class="w-8 h-8 text-success-600 mx-auto mb-2" />
|
||||
<span class="font-medium">Résultats</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
|
||||
<router-link
|
||||
:to="`/assessments/${assessment.id}/edit`"
|
||||
class="card card-body text-center hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CogIcon class="w-8 h-8 text-warning-600 mx-auto mb-2" />
|
||||
<span class="font-medium">Modifier</span>
|
||||
</router-link>
|
||||
|
||||
<button
|
||||
|
||||
<button
|
||||
@click="confirmDelete"
|
||||
class="card card-body text-center hover:shadow-md transition-shadow"
|
||||
class="card card-body text-center hover:shadow-md transition-shadow border-dashed border-red-200 opacity-75"
|
||||
>
|
||||
<TrashIcon class="w-8 h-8 text-danger-600 mx-auto mb-2" />
|
||||
<span class="font-medium">Supprimer</span>
|
||||
@@ -225,12 +220,8 @@ import { useNotificationsStore } from '@/stores/notifications'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import ProgressIndicator from '@/components/assessment/ProgressIndicator.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
|
||||
// Icons
|
||||
const PencilIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>' }
|
||||
const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
|
||||
const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
|
||||
const TrashIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>' }
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import { PencilIcon, ChartIcon, CogIcon, TrashIcon } from '@/components/icons'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<!-- Header compact -->
|
||||
<Breadcrumb :crumbs="isEdit
|
||||
? [{ label: 'Évaluations', to: '/assessments' }, { label: form.title || 'Évaluation', to: `/assessments/${route.params.id}` }, { label: 'Modifier' }]
|
||||
: [{ label: 'Évaluations', to: '/assessments' }, { label: 'Nouvelle évaluation' }]
|
||||
" />
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<router-link
|
||||
:to="isEdit ? `/assessments/${route.params.id}` : '/assessments'"
|
||||
class="text-primary-600 hover:text-primary-800 text-sm font-medium"
|
||||
>
|
||||
← Retour
|
||||
</router-link>
|
||||
<h1 class="text-xl font-semibold text-gray-900">
|
||||
{{ isEdit ? 'Modifier l\'évaluation' : 'Nouvelle évaluation' }}
|
||||
</h1>
|
||||
@@ -273,16 +271,26 @@
|
||||
+ Ajouter un exercice
|
||||
</button>
|
||||
</form>
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.show"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:confirmLabel="confirmDialog.confirmLabel"
|
||||
:variant="confirmDialog.variant"
|
||||
@confirm="confirmDialog.onConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { useAssessmentsStore } from '@/stores/assessments'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import DomainAutocomplete from '@/components/assessment/DomainAutocomplete.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Breadcrumb from '@/components/common/Breadcrumb.vue'
|
||||
import configService from '@/services/config'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -295,6 +303,20 @@ const isEdit = computed(() => !!route.params.id)
|
||||
const submitting = ref(false)
|
||||
const classes = computed(() => classesStore.classes)
|
||||
const competences = ref([])
|
||||
const formDirty = ref(false)
|
||||
|
||||
const confirmDialog = ref({
|
||||
show: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmLabel: 'Confirmer',
|
||||
variant: 'danger',
|
||||
onConfirm: () => {}
|
||||
})
|
||||
|
||||
function showConfirm(opts) {
|
||||
confirmDialog.value = { show: true, ...opts }
|
||||
}
|
||||
|
||||
// Computed pour le récapitulatif
|
||||
const totalElements = computed(() => {
|
||||
@@ -368,6 +390,29 @@ const form = ref({
|
||||
exercises: []
|
||||
})
|
||||
|
||||
watch(form, () => {
|
||||
formDirty.value = true
|
||||
}, { deep: true })
|
||||
|
||||
onBeforeRouteLeave((to, from, next) => {
|
||||
if (formDirty.value && !submitting.value) {
|
||||
if (confirm('Vous avez des modifications non sauvegardées. Quitter cette page ?')) {
|
||||
next()
|
||||
} else {
|
||||
next(false)
|
||||
}
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
function handleBeforeUnload(event) {
|
||||
if (formDirty.value) {
|
||||
event.preventDefault()
|
||||
event.returnValue = 'Vous avez des modifications non sauvegardées.'
|
||||
}
|
||||
}
|
||||
|
||||
function addExercise() {
|
||||
const newOrder = form.value.exercises.length + 1
|
||||
form.value.exercises.push({
|
||||
@@ -387,8 +432,22 @@ function addExercise() {
|
||||
}
|
||||
|
||||
function removeExercise(idx) {
|
||||
const exercise = form.value.exercises[idx]
|
||||
if (isEdit.value && exercise.id) {
|
||||
showConfirm({
|
||||
title: 'Supprimer l\'exercice',
|
||||
message: 'Cet exercice contient potentiellement des notes. Supprimer ?',
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger',
|
||||
onConfirm: () => doRemoveExercise(idx)
|
||||
})
|
||||
return
|
||||
}
|
||||
doRemoveExercise(idx)
|
||||
}
|
||||
|
||||
function doRemoveExercise(idx) {
|
||||
form.value.exercises.splice(idx, 1)
|
||||
// Renumber exercises
|
||||
form.value.exercises.forEach((ex, i) => {
|
||||
ex.order = i + 1
|
||||
})
|
||||
@@ -417,6 +476,17 @@ function addElement(exIdx) {
|
||||
}
|
||||
|
||||
function removeElement(exIdx, elIdx) {
|
||||
const element = form.value.exercises[exIdx].grading_elements[elIdx]
|
||||
if (isEdit.value && element.id) {
|
||||
showConfirm({
|
||||
title: 'Supprimer l\'élément',
|
||||
message: 'Cet élément contient potentiellement des notes. Supprimer ?',
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger',
|
||||
onConfirm: () => form.value.exercises[exIdx].grading_elements.splice(elIdx, 1)
|
||||
})
|
||||
return
|
||||
}
|
||||
form.value.exercises[exIdx].grading_elements.splice(elIdx, 1)
|
||||
}
|
||||
|
||||
@@ -429,10 +499,20 @@ async function submit() {
|
||||
|
||||
const hasEmptyExercises = form.value.exercises.some(ex => ex.grading_elements.length === 0)
|
||||
if (hasEmptyExercises) {
|
||||
if (!confirm('Certains exercices n\'ont pas d\'éléments de notation. Voulez-vous continuer ?')) {
|
||||
return
|
||||
}
|
||||
showConfirm({
|
||||
title: 'Exercices vides',
|
||||
message: 'Certains exercices n\'ont pas d\'éléments de notation. Voulez-vous continuer ?',
|
||||
confirmLabel: 'Continuer',
|
||||
variant: 'warning',
|
||||
onConfirm: () => doSubmit()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
doSubmit()
|
||||
}
|
||||
|
||||
async function doSubmit() {
|
||||
|
||||
// Prepare data for API
|
||||
const data = {
|
||||
@@ -463,22 +543,20 @@ async function submit() {
|
||||
data.class_group_id = form.value.class_group_id
|
||||
}
|
||||
|
||||
console.log('Sending data:', JSON.stringify(data, null, 2))
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await assessmentsStore.updateAssessment(route.params.id, data)
|
||||
notifications.success('Évaluation modifiée avec succès')
|
||||
formDirty.value = false
|
||||
router.push(`/assessments/${route.params.id}`)
|
||||
} else {
|
||||
const created = await assessmentsStore.createAssessment(data)
|
||||
notifications.success('Évaluation créée avec succès')
|
||||
formDirty.value = false
|
||||
router.push(`/assessments/${created.id}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error saving assessment:', e)
|
||||
console.error('Response data:', JSON.stringify(e.response?.data, null, 2))
|
||||
const detail = e.response?.data?.detail
|
||||
const errorMsg = Array.isArray(detail)
|
||||
? detail.map(d => `${d.loc?.join('.')}: ${d.msg}`).join(', ')
|
||||
@@ -526,6 +604,13 @@ onMounted(async () => {
|
||||
// Add first exercise for new assessment
|
||||
addExercise()
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
// Reset dirty flag after initial load
|
||||
nextTick(() => { formDirty.value = false })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
async function loadCompetences() {
|
||||
|
||||
@@ -1,84 +1,43 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Hero section amélioré -->
|
||||
<div class="bg-gradient-to-r from-warning-500 to-orange-600 rounded-2xl p-8 mb-8 text-white">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">Évaluations</h1>
|
||||
<p class="text-warning-100 mb-4">{{ assessments.length }} évaluation(s)</p>
|
||||
|
||||
<!-- Stats rapides -->
|
||||
<div class="flex flex-wrap gap-4 text-sm text-warning-100">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ completedCount }} terminées</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ inProgressCount }} en cours</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>{{ notStartedCount }} non commencées</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<router-link to="/assessments/new" class="btn bg-white text-warning-600 hover:bg-warning-50">
|
||||
<PageHeader title="Évaluations">
|
||||
<template #meta>
|
||||
<span class="badge badge-success">{{ completedCount }} terminées</span>
|
||||
<span class="badge badge-warning">{{ inProgressCount }} en cours</span>
|
||||
<span class="badge badge-danger">{{ notStartedCount }} non commencées</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<router-link to="/assessments/new" class="btn btn-primary">
|
||||
<PlusIcon class="w-5 h-5 mr-2 inline" />
|
||||
Nouvelle évaluation
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card card-body mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="label">Trimestre</label>
|
||||
<select v-model="filters.trimester" class="input" @change="applyFilters">
|
||||
<option :value="null">Tous</option>
|
||||
<option :value="1">Trimestre 1</option>
|
||||
<option :value="2">Trimestre 2</option>
|
||||
<option :value="3">Trimestre 3</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Classe</label>
|
||||
<select v-model="filters.class_id" class="input" @change="applyFilters">
|
||||
<option :value="null">Toutes</option>
|
||||
<option v-for="cls in classes" :key="cls.id" :value="cls.id">
|
||||
{{ cls.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Statut</label>
|
||||
<select v-model="filters.status" class="input" @change="applyFilters">
|
||||
<option value="all">Tous</option>
|
||||
<option value="incomplete">Non terminées</option>
|
||||
<option value="complete">Terminées</option>
|
||||
<option value="not_started">Non commencées</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Tri</label>
|
||||
<select v-model="filters.sort" class="input" @change="applyFilters">
|
||||
<option value="date_desc">Date (récent)</option>
|
||||
<option value="date_asc">Date (ancien)</option>
|
||||
<option value="title">Titre (A-Z)</option>
|
||||
<option value="class">Classe</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Inline filter toolbar -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<TrimesterSelector v-model="filters.trimester" showAll allLabel="Tous" size="sm" />
|
||||
<select v-model="filters.class_id" class="input text-sm py-1.5 w-auto" @change="applyFilters">
|
||||
<option :value="null">Toutes les classes</option>
|
||||
<option v-for="cls in classes" :key="cls.id" :value="cls.id">
|
||||
{{ cls.name }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="filters.status" class="input text-sm py-1.5 w-auto" @change="applyFilters">
|
||||
<option value="all">Tous statuts</option>
|
||||
<option value="incomplete">Non terminées</option>
|
||||
<option value="complete">Terminées</option>
|
||||
<option value="not_started">Non commencées</option>
|
||||
</select>
|
||||
<select v-model="filters.sort" class="input text-sm py-1.5 w-auto" @change="applyFilters">
|
||||
<option value="date_desc">Date (récent)</option>
|
||||
<option value="date_asc">Date (ancien)</option>
|
||||
<option value="title">Titre (A-Z)</option>
|
||||
<option value="class">Classe</option>
|
||||
</select>
|
||||
<button v-if="hasActiveFilters" @click="resetFilters" class="text-xs text-gray-500 hover:text-gray-700 underline">
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
@@ -99,56 +58,61 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Assessments list -->
|
||||
<div v-else class="space-y-4">
|
||||
<router-link
|
||||
v-for="assessment in filteredAssessments"
|
||||
:key="assessment.id"
|
||||
:to="`/assessments/${assessment.id}`"
|
||||
class="card card-body hover:shadow-md transition-shadow block"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold">{{ assessment.title }}</h3>
|
||||
<span class="badge badge-primary">T{{ assessment.trimester }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>{{ assessment.class_name }}</span>
|
||||
<span>{{ formatDate(assessment.date) }}</span>
|
||||
<span>{{ assessment.total_points }} pts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<ProgressIndicator
|
||||
:progress="assessment.progress"
|
||||
size="md"
|
||||
:clickable="true"
|
||||
@click.prevent="goToGrading(assessment.id)"
|
||||
/>
|
||||
<ChevronRightIcon class="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
<!-- Compact table -->
|
||||
<div v-else class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 border-b border-gray-200">
|
||||
<th class="px-4 py-2.5 text-left font-medium text-gray-600">Titre</th>
|
||||
<th class="px-4 py-2.5 text-left font-medium text-gray-600">Classe</th>
|
||||
<th class="px-4 py-2.5 text-left font-medium text-gray-600">Date</th>
|
||||
<th class="px-4 py-2.5 text-center font-medium text-gray-600">Points</th>
|
||||
<th class="px-4 py-2.5 text-center font-medium text-gray-600">Trimestre</th>
|
||||
<th class="px-4 py-2.5 text-center font-medium text-gray-600">Progression</th>
|
||||
<th class="px-4 py-2.5 text-right font-medium text-gray-600"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="assessment in filteredAssessments"
|
||||
:key="assessment.id"
|
||||
class="border-b border-gray-100 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
@click="$router.push(`/assessments/${assessment.id}`)"
|
||||
>
|
||||
<td class="px-4 py-2.5 font-medium text-gray-900">{{ assessment.title }}</td>
|
||||
<td class="px-4 py-2.5 text-gray-600">{{ assessment.class_name }}</td>
|
||||
<td class="px-4 py-2.5 text-gray-600">{{ formatDate(assessment.date) }}</td>
|
||||
<td class="px-4 py-2.5 text-center text-gray-600">{{ assessment.total_points }}</td>
|
||||
<td class="px-4 py-2.5 text-center"><span class="badge badge-primary">T{{ assessment.trimester }}</span></td>
|
||||
<td class="px-4 py-2.5 text-center">
|
||||
<ProgressIndicator :progress="assessment.progress" size="sm" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<router-link
|
||||
:to="`/assessments/${assessment.id}/grading`"
|
||||
class="text-xs font-medium text-primary-600 hover:text-primary-800"
|
||||
@click.stop
|
||||
>
|
||||
Corriger
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAssessmentsStore } from '@/stores/assessments'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import ProgressIndicator from '@/components/assessment/ProgressIndicator.vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import TrimesterSelector from '@/components/common/TrimesterSelector.vue'
|
||||
import { PlusIcon, ClipboardIcon } from '@/components/icons'
|
||||
|
||||
// Icons
|
||||
const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
|
||||
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
|
||||
const ChevronRightIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>' }
|
||||
|
||||
const router = useRouter()
|
||||
const assessmentsStore = useAssessmentsStore()
|
||||
const classesStore = useClassesStore()
|
||||
|
||||
@@ -218,6 +182,10 @@ function formatDate(dateStr) {
|
||||
})
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
filters.value.trimester !== null || filters.value.class_id !== null || filters.value.status !== 'all'
|
||||
)
|
||||
|
||||
function applyFilters() {
|
||||
// Filters are reactive, computed will update automatically
|
||||
}
|
||||
@@ -231,10 +199,6 @@ function resetFilters() {
|
||||
}
|
||||
}
|
||||
|
||||
function goToGrading(id) {
|
||||
router.push(`/assessments/${id}/grading`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
|
||||
@@ -1,63 +1,34 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="px-4 sm:px-6 lg:px-8 py-8">
|
||||
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
||||
|
||||
<template v-else-if="classData">
|
||||
<!-- Hero amélioré -->
|
||||
<div class="bg-gradient-to-br from-blue-50 to-indigo-100 rounded-xl p-6 md:p-8 mb-8">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">{{ classData.name }}</h1>
|
||||
<p class="text-gray-600">{{ classData.year }} - {{ classData.students_count }} élèves</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<router-link :to="`/classes/${classData.id}/students`" class="btn btn-secondary">
|
||||
Élèves
|
||||
</router-link>
|
||||
<router-link :to="`/classes/${classData.id}/council`" class="btn btn-secondary">
|
||||
Conseil
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
:title="classData.name"
|
||||
:subtitle="`${classData.year} \u00b7 ${classData.students_count} élèves`"
|
||||
:breadcrumbs="[{ label: 'Classes', to: '/classes' }, { label: classData.name }]"
|
||||
>
|
||||
<template #actions>
|
||||
<router-link :to="`/classes/${classData.id}/students`" class="btn btn-secondary">
|
||||
Élèves
|
||||
</router-link>
|
||||
<router-link :to="`/classes/${classData.id}/council`" class="btn btn-secondary">
|
||||
Conseil
|
||||
</router-link>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Trimester selector -->
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<!-- Vision annuelle -->
|
||||
<button
|
||||
@click="selectTrimester(null)"
|
||||
class="btn"
|
||||
:class="trimester === null ? 'btn-primary' : 'btn-secondary'"
|
||||
>
|
||||
📊 Vision annuelle
|
||||
</button>
|
||||
|
||||
<!-- Séparateur visuel -->
|
||||
<div class="border-l border-gray-300 h-8 mx-1"></div>
|
||||
|
||||
<!-- Trimestres individuels -->
|
||||
<button
|
||||
v-for="t in [1, 2, 3]"
|
||||
:key="t"
|
||||
@click="selectTrimester(t)"
|
||||
class="btn"
|
||||
:class="trimester === t ? 'btn-primary' : 'btn-secondary'"
|
||||
>
|
||||
Trimestre {{ t }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur de période affichée -->
|
||||
<div class="mt-3 text-center">
|
||||
<p class="text-sm font-medium text-gray-600">
|
||||
{{ trimester === null ? '📊 Toutes les évaluations de l\'année' : `📅 Évaluations du trimestre ${trimester}` }}
|
||||
</p>
|
||||
</div>
|
||||
<TrimesterSelector v-model="trimester" showAll allLabel="Annuel" size="md" @update:modelValue="selectTrimester" />
|
||||
</div>
|
||||
|
||||
<!-- Stats principales - Grid 4 colonnes -->
|
||||
<div v-if="stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div v-if="stats" class="relative">
|
||||
<div v-if="statsLoading" class="absolute inset-0 bg-white/60 z-10 flex items-center justify-center rounded-xl">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<!-- Moyenne classe -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6 hover:shadow-lg transition-all duration-300">
|
||||
<p class="text-sm text-gray-500 mb-1">Moyenne classe</p>
|
||||
@@ -96,6 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domaines et Compétences en 2 colonnes -->
|
||||
<div v-if="stats" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
@@ -233,6 +205,8 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import TrimesterSelector from '@/components/common/TrimesterSelector.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const classesStore = useClassesStore()
|
||||
@@ -243,6 +217,7 @@ const stats = ref(null)
|
||||
const trimester = ref(null) // null = vision annuelle par défaut
|
||||
const sortColumn = ref('name')
|
||||
const sortDirection = ref('asc')
|
||||
const statsLoading = ref(false)
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
@@ -257,7 +232,12 @@ async function fetchData() {
|
||||
|
||||
async function selectTrimester(t) {
|
||||
trimester.value = t
|
||||
stats.value = await classesStore.fetchClassStats(route.params.id, t)
|
||||
statsLoading.value = true
|
||||
try {
|
||||
stats.value = await classesStore.fetchClassStats(route.params.id, t)
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer la liste des évaluations triée par date
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ isEdit ? 'Modifier' : 'Nouvelle' }} classe</h1>
|
||||
|
||||
<form @submit.prevent="submit" class="card card-body space-y-4">
|
||||
<div>
|
||||
<label class="label">Nom de la classe *</label>
|
||||
<input v-model="form.name" type="text" class="input" required placeholder="Ex: 6ème A" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Année scolaire *</label>
|
||||
<input v-model="form.year" type="text" class="input" required placeholder="Ex: 2024-2025" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<textarea v-model="form.description" class="input" rows="3" placeholder="Description optionnelle"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<router-link to="/classes" class="btn btn-secondary">Annuler</router-link>
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? 'Enregistrement...' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const classesStore = useClassesStore()
|
||||
const notifications = useNotificationsStore()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
year: new Date().getFullYear() + '-' + (new Date().getFullYear() + 1),
|
||||
description: ''
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await classesStore.updateClass(route.params.id, form.value)
|
||||
notifications.success('Classe modifiée')
|
||||
} else {
|
||||
await classesStore.createClass(form.value)
|
||||
notifications.success('Classe créée')
|
||||
}
|
||||
router.push('/classes')
|
||||
} catch (e) {
|
||||
notifications.error('Erreur lors de l\'enregistrement')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
const cls = await classesStore.fetchClass(route.params.id)
|
||||
form.value = {
|
||||
name: cls.name,
|
||||
year: cls.year,
|
||||
description: cls.description || ''
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,18 +1,16 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Hero section -->
|
||||
<div class="bg-gradient-to-r from-primary-600 to-primary-800 rounded-2xl p-8 mb-8 text-white">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">Classes</h1>
|
||||
<p class="text-primary-100">{{ classes.length }} classe(s) - {{ totalStudents }} élève(s)</p>
|
||||
</div>
|
||||
<router-link to="/classes/new" class="btn bg-white text-primary-600 hover:bg-primary-50">
|
||||
<PageHeader title="Classes">
|
||||
<template #meta>
|
||||
<span class="badge badge-primary">{{ classes.length }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<button @click="openCreateModal" class="btn btn-primary">
|
||||
<PlusIcon class="w-5 h-5 mr-2 inline" />
|
||||
Nouvelle classe
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Loading state avec skeleton -->
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -25,8 +23,11 @@
|
||||
title="Aucune classe"
|
||||
description="Créez votre première classe pour commencer à gérer vos élèves et évaluations."
|
||||
icon="users"
|
||||
:primaryAction="{ to: '/classes/new', label: 'Créer une classe' }"
|
||||
/>
|
||||
>
|
||||
<template #actions>
|
||||
<button @click="openCreateModal" class="btn btn-primary">Créer une classe</button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
||||
<!-- Classes grid v2.0 -->
|
||||
@@ -50,7 +51,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
|
||||
<!-- Contenu avec actions contextuelles -->
|
||||
<div class="p-4">
|
||||
<!-- Description -->
|
||||
@@ -60,17 +61,17 @@
|
||||
<p v-else class="text-sm text-gray-400 mb-4 italic">
|
||||
Aucune description
|
||||
</p>
|
||||
|
||||
|
||||
<!-- Actions principales avec quantités -->
|
||||
<div class="grid grid-cols-2 gap-2 mb-3">
|
||||
<router-link
|
||||
<router-link
|
||||
:to="`/classes/${cls.id}/students`"
|
||||
:class="`${getAccentBgClass(cls.name)} px-3 py-2.5 rounded-lg text-xs font-medium transition-colors flex items-center justify-center space-x-1`"
|
||||
>
|
||||
<UsersIcon class="w-4 h-4" />
|
||||
<span>{{ cls.students_count || 0 }} Élève{{ (cls.students_count || 0) !== 1 ? 's' : '' }}</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
<router-link
|
||||
:to="`/assessments?class_id=${cls.id}`"
|
||||
class="bg-gray-50 hover:bg-gray-100 text-gray-700 hover:text-gray-900 px-3 py-2.5 rounded-lg text-xs font-medium transition-colors flex items-center justify-center space-x-1"
|
||||
>
|
||||
@@ -78,16 +79,16 @@
|
||||
<span>{{ cls.assessments_count || 0 }} Éval{{ (cls.assessments_count || 0) !== 1 ? 's' : '' }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Actions secondaires -->
|
||||
<div class="pt-3 border-t border-gray-100 flex gap-2">
|
||||
<router-link
|
||||
:to="`/classes/${cls.id}/edit`"
|
||||
<button
|
||||
@click="openEditModal(cls)"
|
||||
class="flex-1 bg-gray-50 hover:bg-gray-100 text-gray-600 hover:text-gray-800 px-3 py-2 rounded-lg text-xs font-medium transition-colors text-center"
|
||||
>
|
||||
Modifier
|
||||
</router-link>
|
||||
<button
|
||||
</button>
|
||||
<button
|
||||
@click.stop="confirmDelete(cls)"
|
||||
class="flex-1 bg-red-50 hover:bg-red-100 text-red-600 hover:text-red-800 px-3 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
>
|
||||
@@ -97,30 +98,125 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Class form modal (create/edit) -->
|
||||
<Modal v-model="showFormModal" :title="editingClass ? 'Modifier la classe' : 'Nouvelle classe'" size="sm">
|
||||
<form @submit.prevent="submitForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Nom de la classe *</label>
|
||||
<input v-model="form.name" type="text" class="input" required placeholder="Ex: 6ème A" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Année scolaire *</label>
|
||||
<input v-model="form.year" type="text" class="input" required placeholder="Ex: 2024-2025" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<textarea v-model="form.description" class="input" rows="3" placeholder="Description optionnelle"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showFormModal = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button @click="submitForm" class="btn btn-primary" :disabled="submitting || !form.name || !form.year">
|
||||
{{ submitting ? 'Enregistrement...' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<Modal v-model="showDeleteModal" title="Confirmer la suppression" size="sm">
|
||||
<p class="text-gray-600">
|
||||
Êtes-vous sûr de vouloir supprimer la classe
|
||||
<strong>{{ classToDelete?.name }}</strong> ?
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Cette classe contient <strong>{{ classToDelete?.students_count || 0 }}</strong> élève(s).
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showDeleteModal = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button @click="executeDelete" class="btn btn-danger">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import SkeletonLoader from '@/components/common/SkeletonLoader.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
|
||||
// Icons
|
||||
const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
|
||||
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
|
||||
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import { PlusIcon, UsersIcon, ClipboardIcon } from '@/components/icons'
|
||||
|
||||
const classesStore = useClassesStore()
|
||||
const notifications = useNotificationsStore()
|
||||
const loading = ref(true)
|
||||
|
||||
// Delete modal
|
||||
const showDeleteModal = ref(false)
|
||||
const classToDelete = ref(null)
|
||||
|
||||
// Form modal
|
||||
const showFormModal = ref(false)
|
||||
const editingClass = ref(null)
|
||||
const submitting = ref(false)
|
||||
const form = ref({ name: '', year: '', description: '' })
|
||||
|
||||
const classes = computed(() => classesStore.classes)
|
||||
const totalStudents = computed(() => classesStore.totalStudents)
|
||||
|
||||
function defaultYear() {
|
||||
const y = new Date().getFullYear()
|
||||
return `${y}-${y + 1}`
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingClass.value = null
|
||||
form.value = { name: '', year: defaultYear(), description: '' }
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(cls) {
|
||||
editingClass.value = cls
|
||||
form.value = { name: cls.name, year: cls.year, description: cls.description || '' }
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!form.value.name || !form.value.year) return
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingClass.value) {
|
||||
await classesStore.updateClass(editingClass.value.id, form.value)
|
||||
notifications.success('Classe modifiée')
|
||||
} else {
|
||||
await classesStore.createClass(form.value)
|
||||
notifications.success('Classe créée')
|
||||
}
|
||||
showFormModal.value = false
|
||||
} catch (e) {
|
||||
notifications.error('Erreur lors de l\'enregistrement')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour obtenir le gradient selon le niveau scolaire
|
||||
function getGradientClass(className) {
|
||||
if (!className) return 'from-gray-500 to-gray-600'
|
||||
|
||||
|
||||
const firstChar = className[0].toUpperCase()
|
||||
const gradients = {
|
||||
'6': 'from-blue-500 to-blue-600',
|
||||
@@ -137,7 +233,7 @@ function getGradientClass(className) {
|
||||
// Fonction pour obtenir les classes d'accent pour les boutons
|
||||
function getAccentBgClass(className) {
|
||||
if (!className) return 'bg-gray-50 hover:bg-gray-100 text-gray-700 hover:text-gray-900'
|
||||
|
||||
|
||||
const firstChar = className[0].toUpperCase()
|
||||
const accents = {
|
||||
'6': 'bg-blue-50 hover:bg-blue-100 text-blue-700 hover:text-blue-900',
|
||||
@@ -153,9 +249,16 @@ function getAccentBgClass(className) {
|
||||
|
||||
// Confirmation de suppression
|
||||
function confirmDelete(cls) {
|
||||
if (confirm(`Êtes-vous sûr de vouloir supprimer la classe "${cls.name}" ?`)) {
|
||||
classesStore.deleteClass(cls.id)
|
||||
classToDelete.value = cls
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
function executeDelete() {
|
||||
if (classToDelete.value) {
|
||||
classesStore.deleteClass(classToDelete.value.id)
|
||||
}
|
||||
showDeleteModal.value = false
|
||||
classToDelete.value = null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<Breadcrumb :crumbs="[
|
||||
{ label: 'Classes', to: '/classes' },
|
||||
{ label: classData?.name, to: `/classes/${classData?.id}` },
|
||||
{ label: 'Élèves' }
|
||||
]" />
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ classData?.name }}</h1>
|
||||
@@ -15,7 +20,7 @@
|
||||
type="checkbox"
|
||||
v-model="includeDeparted"
|
||||
@change="loadStudents"
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700">Inclure les élèves partis</span>
|
||||
</label>
|
||||
@@ -58,7 +63,7 @@
|
||||
@keyup.enter="saveEmail(student)"
|
||||
@keyup.escape="cancelEditEmail"
|
||||
type="email"
|
||||
class="flex-1 px-2 py-1 border border-indigo-300 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="flex-1 px-2 py-1 border border-primary-300 rounded focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="email@exemple.com"
|
||||
/>
|
||||
<button @click="cancelEditEmail" class="text-gray-400 hover:text-gray-600">
|
||||
@@ -71,7 +76,7 @@
|
||||
<span class="flex-1 text-gray-600">{{ student.email || '-' }}</span>
|
||||
<button
|
||||
@click="startEditEmail(student)"
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-indigo-600 transition-opacity"
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-primary-600 transition-opacity"
|
||||
title="Modifier l'email"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -127,7 +132,7 @@
|
||||
<button
|
||||
v-else
|
||||
@click="openReenrollModal(student)"
|
||||
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
class="text-sm text-primary-600 hover:text-primary-800 font-medium"
|
||||
>
|
||||
Réinscrire
|
||||
</button>
|
||||
@@ -154,7 +159,7 @@
|
||||
:class="[
|
||||
'py-4 px-1 border-b-2 font-medium text-sm',
|
||||
addMode === 'new'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
]"
|
||||
>
|
||||
@@ -165,7 +170,7 @@
|
||||
:class="[
|
||||
'py-4 px-1 border-b-2 font-medium text-sm',
|
||||
addMode === 'existing'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
]"
|
||||
>
|
||||
@@ -182,7 +187,7 @@
|
||||
v-model="newStudent.last_name"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -191,7 +196,7 @@
|
||||
v-model="newStudent.first_name"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -199,7 +204,7 @@
|
||||
<input
|
||||
v-model="newStudent.email"
|
||||
type="email"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -208,7 +213,7 @@
|
||||
v-model="newStudent.enrollment_date"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -216,7 +221,7 @@
|
||||
<input
|
||||
v-model="newStudent.enrollment_reason"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Ex: Nouvelle inscription"
|
||||
/>
|
||||
</div>
|
||||
@@ -228,7 +233,7 @@
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Élève *</label>
|
||||
<select
|
||||
v-model="existingStudent.student_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null">Sélectionner un élève...</option>
|
||||
<option v-for="student in availableStudents" :key="student.id" :value="student.id">
|
||||
@@ -242,7 +247,7 @@
|
||||
v-model="existingStudent.enrollment_date"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -250,7 +255,7 @@
|
||||
<input
|
||||
v-model="existingStudent.enrollment_reason"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Ex: Transfert depuis..."
|
||||
/>
|
||||
</div>
|
||||
@@ -258,17 +263,10 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="showAddModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<button @click="showAddModal = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="enrollStudent"
|
||||
:disabled="!canEnroll"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<button @click="enrollStudent" :disabled="!canEnroll" class="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Inscrire
|
||||
</button>
|
||||
</div>
|
||||
@@ -287,7 +285,7 @@
|
||||
v-model="departureDate"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -295,23 +293,16 @@
|
||||
<input
|
||||
v-model="departureReason"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Ex: Déménagement, transfert..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="showDepartureModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<button @click="showDepartureModal = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDeparture"
|
||||
:disabled="!departureDate"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<button @click="confirmDeparture" :disabled="!departureDate" class="btn btn-danger disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Confirmer le départ
|
||||
</button>
|
||||
</div>
|
||||
@@ -330,7 +321,7 @@
|
||||
v-model="reenrollDate"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -338,23 +329,16 @@
|
||||
<input
|
||||
v-model="reenrollReason"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Ex: Retour après absence..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="showReenrollModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<button @click="showReenrollModal = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="confirmReenroll"
|
||||
:disabled="!reenrollDate"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<button @click="confirmReenroll" :disabled="!reenrollDate" class="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Réinscrire
|
||||
</button>
|
||||
</div>
|
||||
@@ -372,6 +356,7 @@ import classesService from '@/services/classes'
|
||||
import studentsService from '@/services/students'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import Breadcrumb from '@/components/common/Breadcrumb.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const classesStore = useClassesStore()
|
||||
@@ -440,7 +425,6 @@ async function loadStudents() {
|
||||
students.value = await classesService.getStudents(id, null, includeDeparted.value)
|
||||
} catch (error) {
|
||||
notifications.error('Erreur lors du chargement des élèves')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,7 +435,6 @@ async function loadAvailableStudents() {
|
||||
availableStudents.value = allStudents.filter(s => !s.current_class_id)
|
||||
} catch (error) {
|
||||
notifications.error('Erreur lors du chargement des élèves disponibles')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +477,6 @@ async function saveEmail(student) {
|
||||
}, 2000)
|
||||
|
||||
notifications.error(error.response?.data?.detail || 'Erreur lors de la mise à jour de l\'email')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +523,6 @@ async function enrollStudent() {
|
||||
await loadStudents()
|
||||
} catch (error) {
|
||||
notifications.error(error.response?.data?.detail || 'Erreur lors de l\'inscription')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,7 +547,6 @@ async function confirmDeparture() {
|
||||
await loadStudents()
|
||||
} catch (error) {
|
||||
notifications.error(error.response?.data?.detail || 'Erreur lors de l\'enregistrement du départ')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,7 +574,6 @@ async function confirmReenroll() {
|
||||
await loadStudents()
|
||||
} catch (error) {
|
||||
notifications.error(error.response?.data?.detail || 'Erreur lors de la réinscription')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-xl p-8 shadow-lg mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">Configuration</h1>
|
||||
<p class="text-lg opacity-90">Personnalisez votre application Notytex</p>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<div class="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="Configuration" />
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="configStore.loading" class="flex justify-center py-12">
|
||||
@@ -54,6 +39,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import ConfigGeneralTab from '@/components/config/ConfigGeneralTab.vue'
|
||||
import ConfigCompetencesTab from '@/components/config/ConfigCompetencesTab.vue'
|
||||
import ConfigDomainsTab from '@/components/config/ConfigDomainsTab.vue'
|
||||
@@ -63,10 +49,10 @@ import ConfigEmailTab from '@/components/config/ConfigEmailTab.vue'
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'competences', label: 'Competences' },
|
||||
{ id: 'general', label: 'Général' },
|
||||
{ id: 'competences', label: 'Compétences' },
|
||||
{ id: 'domains', label: 'Domaines' },
|
||||
{ id: 'scale', label: 'Echelle' },
|
||||
{ id: 'scale', label: 'Échelle' },
|
||||
{ id: 'email', label: 'Email' }
|
||||
]
|
||||
|
||||
|
||||
@@ -1,12 +1,233 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-gradient-to-r from-purple-600 to-purple-800 rounded-2xl p-8 mb-8 text-white">
|
||||
<h1 class="text-3xl font-bold mb-2">Conseil de classe</h1>
|
||||
<p class="text-purple-100">Préparation et appréciations</p>
|
||||
</div>
|
||||
|
||||
<div class="card card-body text-center py-12">
|
||||
<p class="text-gray-500">Interface de conseil de classe à implémenter</p>
|
||||
</div>
|
||||
<div class="px-4 pt-2 pb-1 flex flex-col overflow-hidden h-full">
|
||||
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
||||
|
||||
<template v-else-if="classData">
|
||||
<!-- Breadcrumb -->
|
||||
<Breadcrumb :crumbs="[
|
||||
{ label: 'Classes', to: '/classes' },
|
||||
{ label: classData.name, to: `/classes/${classData.id}` },
|
||||
{ label: 'Conseil' }
|
||||
]" />
|
||||
<!-- Compact toolbar: class info + trimester + stats on one line -->
|
||||
<div v-if="currentStats" class="flex items-center gap-3 py-2 border-b border-gray-200 flex-shrink-0 flex-wrap">
|
||||
<span class="font-semibold text-gray-900 truncate">{{ classData.name }}</span>
|
||||
<span class="text-xs text-gray-400 flex-shrink-0">{{ classData.year }}</span>
|
||||
|
||||
<TrimesterSelector v-model="trimester" @update:modelValue="selectTrimester" />
|
||||
|
||||
<div class="border-l border-gray-200 h-5 flex-shrink-0"></div>
|
||||
|
||||
<div class="flex gap-4 text-xs flex-shrink-0">
|
||||
<span><span class="text-gray-400">Moy</span> <span class="font-bold text-orange-600">{{ currentStats.mean?.toFixed(1) || '-' }}</span></span>
|
||||
<span><span class="text-gray-400">Med</span> <span class="font-bold text-blue-600">{{ currentStats.median?.toFixed(1) || '-' }}</span></span>
|
||||
<span class="hidden sm:inline"><span class="text-gray-400">σ</span> <span class="font-bold text-gray-700">{{ currentStats.std_dev?.toFixed(1) || '-' }}</span></span>
|
||||
<span><span class="text-gray-400">Eleves</span> <span class="font-bold text-gray-900">{{ currentStats.students_count }}</span></span>
|
||||
<span><span class="text-gray-400">Evals</span> <span class="font-bold text-gray-900">{{ currentStats.assessments_total }}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content: list + detail filling remaining space -->
|
||||
<div v-if="rankedStudents.length" class="flex-1 flex flex-col lg:flex-row gap-3 min-h-0 pt-2">
|
||||
<!-- Student list -->
|
||||
<div class="w-full lg:w-64 xl:w-72 lg:flex-shrink-0 overflow-hidden">
|
||||
<CouncilStudentList
|
||||
:students="displayedStudents"
|
||||
:selectedStudentId="selectedStudentId"
|
||||
:classMean="currentStats?.mean ?? null"
|
||||
v-model:search="searchQuery"
|
||||
@select="selectStudent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Student detail -->
|
||||
<div class="flex-1 min-w-0 overflow-hidden">
|
||||
<CouncilStudentDetail
|
||||
v-if="selectedStudent"
|
||||
:student="selectedStudent"
|
||||
:classStats="currentStats"
|
||||
:allTrimesterStats="allTrimesterStatsArray"
|
||||
:currentTrimester="trimester"
|
||||
:totalStudents="rankedStudents.length"
|
||||
:hasPrev="selectedIndex > 0"
|
||||
:hasNext="selectedIndex < displayedStudents.length - 1"
|
||||
:classDomainsStats="currentStats?.domains_stats || []"
|
||||
:classCompetencesStats="currentStats?.competences_stats || []"
|
||||
@prev="navigateStudent(-1)"
|
||||
@next="navigateStudent(1)"
|
||||
/>
|
||||
<div v-else class="bg-white rounded-xl shadow-md p-12 text-center">
|
||||
<p class="text-gray-400">Sélectionnez un élève dans la liste</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="currentStats" class="flex-1 flex items-center justify-center">
|
||||
<p class="text-gray-500">Aucune donnée disponible pour le trimestre {{ trimester }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import { classesService } from '@/services/classes'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import CouncilStudentList from '@/components/council/CouncilStudentList.vue'
|
||||
import CouncilStudentDetail from '@/components/council/CouncilStudentDetail.vue'
|
||||
import TrimesterSelector from '@/components/common/TrimesterSelector.vue'
|
||||
import Breadcrumb from '@/components/common/Breadcrumb.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const classesStore = useClassesStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const classData = ref(null)
|
||||
const trimester = ref(1)
|
||||
const currentStats = ref(null)
|
||||
const allTrimesterStats = ref({})
|
||||
const selectedStudentId = ref(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// All students with rank + trend (unfiltered, used for global rank)
|
||||
const rankedStudents = computed(() => {
|
||||
if (!currentStats.value?.student_averages) return []
|
||||
const students = currentStats.value.student_averages.map(s => ({ ...s }))
|
||||
|
||||
const withAvg = students.filter(s => s.average !== null)
|
||||
withAvg.sort((a, b) => b.average - a.average)
|
||||
withAvg.forEach((s, i) => { s.rank = i + 1 })
|
||||
const withoutAvg = students.filter(s => s.average === null)
|
||||
|
||||
const prevTrimester = trimester.value > 1 ? allTrimesterStats.value[trimester.value - 1] : null
|
||||
for (const s of students) {
|
||||
if (prevTrimester) {
|
||||
const prevStudent = prevTrimester.student_averages?.find(ps => ps.student_id === s.student_id)
|
||||
if (prevStudent?.average != null && s.average !== null) {
|
||||
const diff = s.average - prevStudent.average
|
||||
s.trend = diff > 0.5 ? 'up' : diff < -0.5 ? 'down' : 'stable'
|
||||
} else {
|
||||
s.trend = null
|
||||
}
|
||||
} else {
|
||||
s.trend = null
|
||||
}
|
||||
}
|
||||
|
||||
return [...withAvg, ...withoutAvg]
|
||||
})
|
||||
|
||||
// Displayed students: filtered by search, sorted alphabetically
|
||||
const displayedStudents = computed(() => {
|
||||
let list = rankedStudents.value
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
list = list.filter(s =>
|
||||
`${s.last_name} ${s.first_name}`.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return [...list].sort((a, b) => {
|
||||
const nameA = `${a.last_name} ${a.first_name}`.toLowerCase()
|
||||
const nameB = `${b.last_name} ${b.first_name}`.toLowerCase()
|
||||
return nameA.localeCompare(nameB, 'fr')
|
||||
})
|
||||
})
|
||||
|
||||
watch(searchQuery, () => {
|
||||
if (!displayedStudents.value.length) return
|
||||
const still = displayedStudents.value.find(s => s.student_id === selectedStudentId.value)
|
||||
if (!still) {
|
||||
selectedStudentId.value = displayedStudents.value[0].student_id
|
||||
}
|
||||
})
|
||||
|
||||
const allTrimesterStatsArray = computed(() => {
|
||||
const result = []
|
||||
for (let t = 1; t <= 3; t++) {
|
||||
const stats = allTrimesterStats.value[t]
|
||||
if (stats) result.push({ trimester: t, stats })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const selectedStudent = computed(() => {
|
||||
if (selectedStudentId.value === null) return null
|
||||
return displayedStudents.value.find(s => s.student_id === selectedStudentId.value) || null
|
||||
})
|
||||
|
||||
const selectedIndex = computed(() => {
|
||||
if (selectedStudentId.value === null) return -1
|
||||
return displayedStudents.value.findIndex(s => s.student_id === selectedStudentId.value)
|
||||
})
|
||||
|
||||
function selectStudent(id) {
|
||||
selectedStudentId.value = id
|
||||
}
|
||||
|
||||
function navigateStudent(delta) {
|
||||
const newIndex = selectedIndex.value + delta
|
||||
if (newIndex >= 0 && newIndex < displayedStudents.value.length) {
|
||||
selectedStudentId.value = displayedStudents.value[newIndex].student_id
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
navigateStudent(-1)
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
navigateStudent(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const id = route.params.id
|
||||
classData.value = await classesStore.fetchClass(id)
|
||||
|
||||
const promises = [1, 2, 3].map(t =>
|
||||
classesService.getStats(id, t).then(data => ({ t, data })).catch(() => ({ t, data: null }))
|
||||
)
|
||||
const results = await Promise.all(promises)
|
||||
for (const { t, data } of results) {
|
||||
if (data) allTrimesterStats.value[t] = data
|
||||
}
|
||||
|
||||
currentStats.value = allTrimesterStats.value[trimester.value] || null
|
||||
|
||||
if (displayedStudents.value.length && !selectedStudentId.value) {
|
||||
selectedStudentId.value = displayedStudents.value[0].student_id
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectTrimester(t) {
|
||||
trimester.value = t
|
||||
currentStats.value = allTrimesterStats.value[t] || null
|
||||
if (selectedStudentId.value) {
|
||||
const stillExists = displayedStudents.value.find(s => s.student_id === selectedStudentId.value)
|
||||
if (!stillExists && displayedStudents.value.length) {
|
||||
selectedStudentId.value = displayedStudents.value[0].student_id
|
||||
}
|
||||
} else if (displayedStudents.value.length) {
|
||||
selectedStudentId.value = displayedStudents.value[0].student_id
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Hero section -->
|
||||
<div class="bg-gradient-to-r from-primary-600 to-primary-800 rounded-2xl p-8 mb-8 text-white">
|
||||
<h1 class="text-3xl font-bold mb-2">Tableau de bord</h1>
|
||||
<p class="text-primary-100">Bienvenue sur Notytex - Gestion des évaluations scolaires</p>
|
||||
</div>
|
||||
<PageHeader title="Tableau de bord" />
|
||||
|
||||
<!-- Loading state -->
|
||||
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
||||
@@ -57,9 +53,12 @@
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<div class="card card-body">
|
||||
<router-link
|
||||
to="/assessments"
|
||||
class="card card-body hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-lg bg-danger-100 text-danger-600">
|
||||
<div class="p-3 rounded-lg bg-danger-100 text-danger-600 group-hover:bg-danger-200 transition-colors">
|
||||
<PencilIcon class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
@@ -67,7 +66,7 @@
|
||||
<p class="text-2xl font-bold">{{ assessmentsStore.incompleteAssessments.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions -->
|
||||
@@ -149,16 +148,9 @@ import { onMounted, ref } from 'vue'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import { useAssessmentsStore } from '@/stores/assessments'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import ProgressIndicator from '@/components/assessment/ProgressIndicator.vue'
|
||||
|
||||
// Icons
|
||||
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
|
||||
const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
|
||||
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
|
||||
const PencilIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>' }
|
||||
const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
|
||||
const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
|
||||
const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
|
||||
import { UsersIcon, AcademicCapIcon, ClipboardIcon, PencilIcon, PlusIcon, ChartIcon, CogIcon } from '@/components/icons'
|
||||
|
||||
const classesStore = useClassesStore()
|
||||
const assessmentsStore = useAssessmentsStore()
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
<template>
|
||||
<div class="h-screen w-screen overflow-hidden flex flex-col bg-gray-50">
|
||||
<div class="h-full overflow-hidden flex flex-col bg-gray-50">
|
||||
<!-- Loading -->
|
||||
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
||||
|
||||
<template v-else-if="assessment">
|
||||
<!-- Header compact -->
|
||||
<header class="flex-shrink-0 bg-white border-b border-gray-200 px-4 py-3">
|
||||
<header class="flex-shrink-0 bg-white border-b border-gray-200 px-4 py-2">
|
||||
<Breadcrumb :crumbs="[
|
||||
{ label: 'Évaluations', to: '/assessments' },
|
||||
{ label: assessment.title, to: `/assessments/${assessment.id}` },
|
||||
{ label: 'Notation' }
|
||||
]" />
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Gauche : Navigation + Titre -->
|
||||
<!-- Gauche : Titre -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<router-link
|
||||
:to="{ name: 'assessment-detail', params: { id: assessment.id }}"
|
||||
class="text-gray-500 hover:text-gray-700 flex items-center text-sm"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour
|
||||
</router-link>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-gray-900">{{ assessment.title }}</h1>
|
||||
<p class="text-sm text-gray-500">{{ assessment.class_name }} - Saisie des notes</p>
|
||||
@@ -66,7 +62,7 @@
|
||||
<button
|
||||
@click="saveAll"
|
||||
:disabled="saving"
|
||||
class="px-4 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
class="px-4 py-1.5 text-sm bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<svg v-if="saving" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
@@ -79,9 +75,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Guide de saisie -->
|
||||
<div class="mt-3 bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-lg px-4 py-2">
|
||||
<div class="mt-3 bg-gradient-to-r from-primary-50 to-accent-50 border border-primary-200 rounded-lg px-4 py-2">
|
||||
<div class="flex flex-wrap items-center gap-4 text-xs">
|
||||
<span class="font-semibold text-blue-900">Guide :</span>
|
||||
<span class="font-semibold text-primary-900">Guide :</span>
|
||||
<span><strong>Notes</strong> = valeurs décimales (ex: 15.5)</span>
|
||||
<span><strong>Scores</strong> = 0-3 (0=Non acquis, 3=Expert)</span>
|
||||
<span>
|
||||
@@ -98,18 +94,18 @@
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<!-- Header exercices -->
|
||||
<thead class="sticky top-0 z-30">
|
||||
<tr class="bg-gradient-to-r from-indigo-100 to-purple-100 border-b-2 border-indigo-300">
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-700 uppercase sticky left-0 bg-gradient-to-r from-indigo-100 to-purple-100 border-r border-indigo-200 min-w-[200px]">
|
||||
<tr class="bg-gradient-to-r from-primary-100 to-accent-100 border-b-2 border-primary-300">
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-700 uppercase sticky left-0 bg-gradient-to-r from-primary-100 to-accent-100 border-r border-primary-200 min-w-[200px]">
|
||||
Élève
|
||||
</th>
|
||||
<th
|
||||
v-for="(group, exerciseId) in exerciseGroups"
|
||||
:key="exerciseId"
|
||||
:colspan="group.elements.length"
|
||||
class="px-2 py-2 text-center text-sm font-bold text-indigo-900 border-x border-indigo-300"
|
||||
class="px-2 py-2 text-center text-sm font-bold text-primary-900 border-x border-primary-300"
|
||||
>
|
||||
{{ group.title }}
|
||||
<div class="text-xs font-normal text-indigo-700">{{ group.elements.length }} élément(s)</div>
|
||||
<div class="text-xs font-normal text-primary-700">{{ group.elements.length }} élément(s)</div>
|
||||
</th>
|
||||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-700 uppercase min-w-[80px]">
|
||||
Total
|
||||
@@ -127,7 +123,7 @@
|
||||
v-model="studentFilter"
|
||||
type="text"
|
||||
placeholder="Filtrer les élèves..."
|
||||
class="w-full px-2 py-1 pl-7 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
class="w-full px-2 py-1 pl-7 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
@focus="filterFocused = true"
|
||||
@blur="filterFocused = false"
|
||||
/>
|
||||
@@ -169,13 +165,13 @@
|
||||
<div class="mt-1 flex justify-center gap-1 flex-wrap">
|
||||
<span
|
||||
v-if="element.grading_type === 'score'"
|
||||
class="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800"
|
||||
class="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-accent-100 text-accent-800"
|
||||
title="Évaluation par compétences"
|
||||
>
|
||||
Score
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
|
||||
class="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-primary-100 text-primary-800"
|
||||
title="Points maximum"
|
||||
>
|
||||
{{ element.max_points }}pts
|
||||
@@ -201,7 +197,7 @@
|
||||
<span v-if="(element.domain || element.domain_name) && element.skill" class="text-gray-400"> / </span>
|
||||
<span
|
||||
v-if="element.skill"
|
||||
class="text-indigo-600"
|
||||
class="text-primary-600"
|
||||
>
|
||||
{{ element.skill }}
|
||||
</span>
|
||||
@@ -237,7 +233,7 @@
|
||||
</div>
|
||||
<button
|
||||
@click="openQuickComplete(student.id)"
|
||||
class="ml-2 text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded transition-colors"
|
||||
class="ml-2 text-xs bg-primary-100 hover:bg-primary-200 text-primary-700 px-2 py-1 rounded transition-colors"
|
||||
title="Compléter les champs vides"
|
||||
>
|
||||
⚡
|
||||
@@ -261,7 +257,7 @@
|
||||
@focus="setCurrentPosition(studentIdx, elementIdx)"
|
||||
:data-row="studentIdx"
|
||||
:data-col="elementIdx"
|
||||
class="w-full text-xs border rounded py-1 px-1 focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all"
|
||||
class="w-full text-xs border rounded py-1 px-1 focus:ring-2 focus:ring-accent-500 focus:border-accent-500 transition-all"
|
||||
:class="getInputClass(getGrade(student.id, element.id), 'score')"
|
||||
:style="getInputStyle(getGrade(student.id, element.id), 'score')"
|
||||
>
|
||||
@@ -288,7 +284,7 @@
|
||||
:data-row="studentIdx"
|
||||
:data-col="elementIdx"
|
||||
:placeholder="`0-${element.max_points}`"
|
||||
class="w-full text-xs border rounded py-1 px-2 text-center focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all"
|
||||
class="w-full text-xs border rounded py-1 px-2 text-center focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all"
|
||||
:class="getInputClass(getGrade(student.id, element.id), 'notes', element.max_points)"
|
||||
:style="getInputStyle(getGrade(student.id, element.id), 'notes', element.max_points)"
|
||||
/>
|
||||
@@ -313,9 +309,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
@click="resetForm"
|
||||
class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 transition-colors"
|
||||
<button
|
||||
@click="showResetModal = true"
|
||||
class="px-3 py-1 text-xs border border-red-200 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
@@ -358,7 +354,7 @@
|
||||
</label>
|
||||
<select
|
||||
v-model="quickCompleteValue"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value=".">. - Pas de réponse</option>
|
||||
<option value="d">d - Dispensé</option>
|
||||
@@ -369,7 +365,7 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="quickCompleteOverwrite"
|
||||
class="mr-3 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
class="mr-3 h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-700">Écraser les valeurs existantes</span>
|
||||
</label>
|
||||
@@ -380,42 +376,67 @@
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="showQuickComplete = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<button @click="showQuickComplete = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="executeQuickComplete"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
<button @click="executeQuickComplete" class="btn btn-primary">
|
||||
Appliquer
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Toast -->
|
||||
<Transition name="toast">
|
||||
<div
|
||||
v-if="toast.show"
|
||||
class="fixed bottom-4 right-4 z-50"
|
||||
>
|
||||
<div
|
||||
class="px-4 py-3 rounded-lg shadow-lg flex items-center"
|
||||
:class="toastClass"
|
||||
>
|
||||
<svg v-if="toast.type === 'success'" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg v-else-if="toast.type === 'error'" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span>{{ toast.message }}</span>
|
||||
<!-- Modal réinitialisation -->
|
||||
<Modal v-model="showResetModal" title="Réinitialiser les notes" size="sm">
|
||||
<p class="text-gray-600">
|
||||
Réinitialiser toutes les notes ? Cette action est irréversible.
|
||||
</p>
|
||||
<template #footer>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button @click="showResetModal = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button @click="resetForm" class="btn btn-danger">
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal erreurs de validation -->
|
||||
<Modal v-model="showErrorsModal" title="Valeurs invalides" size="md">
|
||||
<p class="text-gray-600 mb-3">
|
||||
{{ invalidEntries.length }} valeur(s) invalide(s) n'ont pas été sauvegardées :
|
||||
</p>
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Élève</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Élément</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Valeur</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Erreur</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="(entry, idx) in invalidEntries" :key="idx">
|
||||
<td class="px-3 py-2 text-gray-900">{{ entry.studentName }}</td>
|
||||
<td class="px-3 py-2 text-gray-700">{{ entry.elementLabel }}</td>
|
||||
<td class="px-3 py-2 font-mono text-red-600">{{ entry.value }}</td>
|
||||
<td class="px-3 py-2 text-red-500 text-xs">{{ entry.error }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Transition>
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button @click="showErrorsModal = false" class="btn btn-primary">
|
||||
Compris
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -425,90 +446,20 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAssessmentsStore } from '@/stores/assessments'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import classesService from '@/services/classes'
|
||||
import assessmentsService from '@/services/assessments'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import Breadcrumb from '@/components/common/Breadcrumb.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const assessmentsStore = useAssessmentsStore()
|
||||
const configStore = useConfigStore()
|
||||
const notifications = useNotificationsStore()
|
||||
|
||||
// Color interpolation functions
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : { r: 0, g: 0, b: 0 }
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
let h, s, l = (max + min) / 2
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
return { h: h * 360, s: s * 100, l: l * 100 }
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
h /= 360; s /= 100; l /= 100
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1/6) return p + (q - p) * 6 * t
|
||||
if (t < 1/2) return q
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
||||
return p
|
||||
}
|
||||
let r, g, b
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1/3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1/3)
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
|
||||
}
|
||||
|
||||
function interpolateColorHSL(color1, color2, factor) {
|
||||
const rgb1 = hexToRgb(color1)
|
||||
const rgb2 = hexToRgb(color2)
|
||||
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b)
|
||||
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b)
|
||||
|
||||
let deltaH = hsl2.h - hsl1.h
|
||||
if (deltaH > 180) hsl2.h -= 360
|
||||
else if (deltaH < -180) hsl2.h += 360
|
||||
|
||||
const h = ((hsl1.h + (hsl2.h - hsl1.h) * factor) % 360 + 360) % 360
|
||||
const s = hsl1.s + (hsl2.s - hsl1.s) * factor
|
||||
const l = hsl1.l + (hsl2.l - hsl1.l) * factor
|
||||
|
||||
const rgb = hslToRgb(h, s, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
import { interpolateColorHSL } from '@/utils/colors'
|
||||
|
||||
// State
|
||||
const loading = ref(true)
|
||||
@@ -527,13 +478,14 @@ const studentFilter = ref('')
|
||||
const filterFocused = ref(false)
|
||||
const showKeyboardHelp = ref(false)
|
||||
const showQuickComplete = ref(false)
|
||||
const showResetModal = ref(false)
|
||||
const showErrorsModal = ref(false)
|
||||
const invalidEntries = ref([])
|
||||
const quickCompleteStudentId = ref(null)
|
||||
const quickCompleteValue = ref('.')
|
||||
const quickCompleteOverwrite = ref(false)
|
||||
const currentPosition = ref(null)
|
||||
|
||||
// Toast
|
||||
const toast = ref({ show: false, message: '', type: 'success' })
|
||||
|
||||
// Computed
|
||||
const allElements = computed(() => {
|
||||
@@ -597,15 +549,6 @@ const progressColorClass = computed(() => {
|
||||
|
||||
const hasUnsavedChanges = computed(() => unsavedChanges.value.size > 0)
|
||||
|
||||
const toastClass = computed(() => {
|
||||
const classes = {
|
||||
success: 'bg-green-500 text-white',
|
||||
error: 'bg-red-500 text-white',
|
||||
info: 'bg-blue-500 text-white',
|
||||
warning: 'bg-orange-500 text-white'
|
||||
}
|
||||
return classes[toast.value.type] || classes.info
|
||||
})
|
||||
|
||||
// Options d'échelle depuis la config
|
||||
const scaleOptions = computed(() => {
|
||||
@@ -1091,12 +1034,12 @@ async function saveAll() {
|
||||
saving.value = true
|
||||
try {
|
||||
const gradesArray = []
|
||||
const errors = []
|
||||
|
||||
const collectedErrors = []
|
||||
|
||||
for (const key in grades.value) {
|
||||
const [studentId, elementId] = key.split('_').map(Number)
|
||||
const value = grades.value[key]
|
||||
|
||||
|
||||
if (value !== '') {
|
||||
// Valider avant d'ajouter
|
||||
const element = getElementById(elementId)
|
||||
@@ -1104,11 +1047,16 @@ async function saveAll() {
|
||||
const validation = validateGradeValue(value, element.grading_type, element.max_points)
|
||||
if (!validation.valid) {
|
||||
const student = students.value.find(s => s.id === studentId)
|
||||
errors.push(`${student?.last_name || 'Élève'} - ${element.label || element.name}: ${validation.error}`)
|
||||
collectedErrors.push({
|
||||
studentName: `${student?.last_name || 'Élève'} ${student?.first_name || ''}`.trim(),
|
||||
elementLabel: element.label || element.name,
|
||||
value: value,
|
||||
error: validation.error
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
gradesArray.push({
|
||||
student_id: studentId,
|
||||
grading_element_id: elementId,
|
||||
@@ -1116,17 +1064,17 @@ async function saveAll() {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
showToast(`${errors.length} valeur(s) invalide(s) ignorée(s)`, 'warning')
|
||||
console.warn('Erreurs de validation:', errors)
|
||||
|
||||
if (collectedErrors.length > 0) {
|
||||
invalidEntries.value = collectedErrors
|
||||
showErrorsModal.value = true
|
||||
}
|
||||
|
||||
|
||||
if (gradesArray.length > 0) {
|
||||
await assessmentsStore.saveGrades(assessment.value.id, gradesArray)
|
||||
unsavedChanges.value.clear()
|
||||
showToast('Notes sauvegardées avec succès', 'success')
|
||||
} else if (errors.length === 0) {
|
||||
} else if (collectedErrors.length === 0) {
|
||||
showToast('Aucune note à sauvegarder', 'info')
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1137,22 +1085,17 @@ async function saveAll() {
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
if (!confirm('Êtes-vous sûr de vouloir réinitialiser toutes les notes ? Cette action est irréversible.')) {
|
||||
return
|
||||
}
|
||||
|
||||
grades.value = {}
|
||||
unsavedChanges.value.clear()
|
||||
undoStack.value = []
|
||||
showResetModal.value = false
|
||||
showToast('Formulaire réinitialisé', 'info')
|
||||
}
|
||||
|
||||
// Toast
|
||||
// Notification helper
|
||||
function showToast(message, type = 'success') {
|
||||
toast.value = { show: true, message, type }
|
||||
setTimeout(() => {
|
||||
toast.value.show = false
|
||||
}, 3000)
|
||||
const fn = { success: 'success', error: 'error', warning: 'warning', info: 'info' }
|
||||
notifications[fn[type] || 'info'](message)
|
||||
}
|
||||
|
||||
// Protection fermeture
|
||||
@@ -1229,14 +1172,4 @@ onUnmounted(() => {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,74 +1,22 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="px-4 sm:px-6 lg:px-8 py-8">
|
||||
<LoadingSpinner v-if="loading" text="Chargement des résultats..." fullPage />
|
||||
|
||||
<template v-else-if="results">
|
||||
<!-- Header amélioré avec métadonnées -->
|
||||
<div class="bg-gradient-to-r from-success-500 to-emerald-600 rounded-2xl p-8 mb-8 text-white">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">{{ results.assessment_title }}</h1>
|
||||
<p class="text-success-100 mb-4">Résultats de l'évaluation</p>
|
||||
|
||||
<!-- Métadonnées avec icônes -->
|
||||
<div class="flex flex-wrap gap-4 text-sm text-success-100">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>{{ gradedStudents.length }}/{{ results.students_scores?.length || 0 }} élèves évalués</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<span>Moy: {{ calculatedStats?.mean?.toFixed(1) || '-' }}/20</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
<span>Max: {{ calculatedStats?.max?.toFixed(1) || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton retour -->
|
||||
<router-link
|
||||
:to="`/assessments/${results.assessment_id}`"
|
||||
class="btn bg-white/20 hover:bg-white/30 text-white"
|
||||
>
|
||||
Retour
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
:title="results.assessment_title"
|
||||
:subtitle="`Résultats \u00b7 ${gradedStudents.length}/${results.students_scores?.length || 0} élèves évalués`"
|
||||
:breadcrumbs="[{ label: 'Évaluations', to: '/assessments' }, { label: results.assessment_title, to: `/assessments/${results.assessment_id}` }, { label: 'Résultats' }]"
|
||||
/>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-8">
|
||||
<div class="card card-body text-center">
|
||||
<p class="text-sm text-gray-500">Évalués</p>
|
||||
<p class="text-2xl font-bold">{{ gradedStudents.length }}<span class="text-sm text-gray-400">/{{ results.students_scores?.length }}</span></p>
|
||||
</div>
|
||||
<div class="card card-body text-center">
|
||||
<p class="text-sm text-gray-500">Moyenne</p>
|
||||
<p class="text-2xl font-bold">{{ calculatedStats?.mean?.toFixed(2) || '-' }}</p>
|
||||
</div>
|
||||
<div class="card card-body text-center">
|
||||
<p class="text-sm text-gray-500">Médiane</p>
|
||||
<p class="text-2xl font-bold">{{ calculatedStats?.median?.toFixed(2) || '-' }}</p>
|
||||
</div>
|
||||
<div class="card card-body text-center">
|
||||
<p class="text-sm text-gray-500">Écart-type</p>
|
||||
<p class="text-2xl font-bold">{{ calculatedStats?.std_dev?.toFixed(2) || '-' }}</p>
|
||||
</div>
|
||||
<div class="card card-body text-center">
|
||||
<p class="text-sm text-gray-500">Min</p>
|
||||
<p class="text-2xl font-bold">{{ calculatedStats?.min?.toFixed(2) || '-' }}</p>
|
||||
</div>
|
||||
<div class="card card-body text-center">
|
||||
<p class="text-sm text-gray-500">Max</p>
|
||||
<p class="text-2xl font-bold">{{ calculatedStats?.max?.toFixed(2) || '-' }}</p>
|
||||
</div>
|
||||
<!-- Statistics bar -->
|
||||
<div class="flex items-center gap-6 px-4 py-3 bg-white rounded-lg border border-gray-200 mb-6 text-sm">
|
||||
<div><span class="text-gray-500">Évalués</span> <span class="font-bold">{{ gradedStudents.length }}<span class="text-gray-400">/{{ results.students_scores?.length }}</span></span></div>
|
||||
<div><span class="text-gray-500">Moy</span> <span class="font-bold">{{ calculatedStats?.mean?.toFixed(2) || '-' }}</span></div>
|
||||
<div><span class="text-gray-500">Med</span> <span class="font-bold">{{ calculatedStats?.median?.toFixed(2) || '-' }}</span></div>
|
||||
<div><span class="text-gray-500">E-T</span> <span class="font-bold">{{ calculatedStats?.std_dev?.toFixed(2) || '-' }}</span></div>
|
||||
<div><span class="text-gray-500">Min</span> <span class="font-bold">{{ calculatedStats?.min?.toFixed(2) || '-' }}</span></div>
|
||||
<div><span class="text-gray-500">Max</span> <span class="font-bold">{{ calculatedStats?.max?.toFixed(2) || '-' }}</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Avertissement si élèves non évalués -->
|
||||
@@ -202,80 +150,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar de sélection (mode sélection activé) -->
|
||||
<div v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="card mb-6 border-2 border-blue-500 bg-blue-50">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-semibold text-blue-900">Mode Sélection Activé</h3>
|
||||
<p class="text-sm text-blue-700">
|
||||
Cliquez sur les cases pour sélectionner les élèves qui recevront leur bilan
|
||||
<span v-if="selectedStudents.length > 0" class="font-semibold">
|
||||
({{ selectedStudents.length }} sélectionné{{selectedStudents.length > 1 ? 's' : ''}})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="toggleSelectAll"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>
|
||||
{{ allSelected ? 'Tout désélectionner' : 'Tout sélectionner' }}
|
||||
</button>
|
||||
<button
|
||||
@click="cancelSelectionMode"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="openSendModal"
|
||||
:disabled="selectedStudents.length === 0"
|
||||
class="btn btn-primary shadow-lg"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': selectedStudents.length === 0 }"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Envoyer à {{ selectedStudents.length }} élève{{selectedStudents.length > 1 ? 's' : ''}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Student scores table -->
|
||||
<div class="card">
|
||||
<div class="card-header flex justify-between items-center">
|
||||
<h2 class="text-lg font-semibold">Détail par élève</h2>
|
||||
|
||||
<!-- Mode Normal : Bouton pour activer la sélection -->
|
||||
<button
|
||||
v-if="!selectionMode && gradedStudentsWithEmail.length > 0"
|
||||
@click="activateSelectionMode"
|
||||
class="btn btn-primary"
|
||||
<button
|
||||
v-if="gradedStudentsWithEmail.length > 0"
|
||||
@click="toggleSelectAll"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
📧 Envoyer des bilans
|
||||
{{ allSelected ? 'Tout désélectionner' : 'Tout sélectionner' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="w-16">
|
||||
<th v-if="gradedStudentsWithEmail.length > 0" class="w-16">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
@@ -295,10 +187,10 @@
|
||||
:key="student.student_id"
|
||||
:class="{
|
||||
'bg-gray-50 opacity-60': !isStudentGraded(student),
|
||||
'bg-blue-50 border-l-4 border-blue-500': selectionMode && isSelected(student.student_id)
|
||||
'bg-blue-50 border-l-4 border-blue-500': isSelected(student.student_id)
|
||||
}"
|
||||
>
|
||||
<td v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="w-16">
|
||||
<td v-if="gradedStudentsWithEmail.length > 0" class="w-16">
|
||||
<input
|
||||
v-if="isStudentGraded(student) && student.email"
|
||||
type="checkbox"
|
||||
@@ -379,6 +271,31 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Sticky bottom bar for sending emails -->
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="selectedStudents.length > 0"
|
||||
class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg z-40 px-6 py-3"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-600">
|
||||
<strong>{{ selectedStudents.length }}</strong> élève{{ selectedStudents.length > 1 ? 's' : '' }} sélectionné{{ selectedStudents.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<button @click="selectedStudents = []" class="text-sm text-gray-500 hover:text-gray-700">
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="openSendModal"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Envoyer les bilans à {{ selectedStudents.length }} élève{{ selectedStudents.length > 1 ? 's' : '' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Modal d'envoi de bilans -->
|
||||
<SendReportsModal
|
||||
v-if="showSendModal"
|
||||
@@ -399,6 +316,7 @@ import { Bar } from 'vue-chartjs'
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import SendReportsModal from '@/components/assessment/SendReportsModal.vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
@@ -409,88 +327,8 @@ const configStore = useConfigStore()
|
||||
// État pour la sélection d'élèves et l'envoi d'emails
|
||||
const selectedStudents = ref([])
|
||||
const showSendModal = ref(false)
|
||||
const selectionMode = ref(false) // Mode sélection activé/désactivé
|
||||
|
||||
// Color interpolation functions
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : { r: 0, g: 0, b: 0 }
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
let h, s, l = (max + min) / 2
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
return { h: h * 360, s: s * 100, l: l * 100 }
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
h /= 360; s /= 100; l /= 100
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1/6) return p + (q - p) * 6 * t
|
||||
if (t < 1/2) return q
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
||||
return p
|
||||
}
|
||||
let r, g, b
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1/3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1/3)
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
|
||||
}
|
||||
|
||||
function interpolateColorHSL(color1, color2, factor) {
|
||||
const rgb1 = hexToRgb(color1)
|
||||
const rgb2 = hexToRgb(color2)
|
||||
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b)
|
||||
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b)
|
||||
|
||||
let deltaH = hsl2.h - hsl1.h
|
||||
if (deltaH > 180) hsl2.h -= 360
|
||||
else if (deltaH < -180) hsl2.h += 360
|
||||
|
||||
const h = ((hsl1.h + (hsl2.h - hsl1.h) * factor) % 360 + 360) % 360
|
||||
const s = hsl1.s + (hsl2.s - hsl1.s) * factor
|
||||
const l = hsl1.l + (hsl2.l - hsl1.l) * factor
|
||||
|
||||
const rgb = hslToRgb(h, s, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
function getTextColorForBg(bgColor) {
|
||||
const rgb = hexToRgb(bgColor)
|
||||
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
|
||||
return brightness > 128 ? '#000000' : '#ffffff'
|
||||
}
|
||||
import { interpolateColorHSL, getTextColorForBg } from '@/utils/colors'
|
||||
|
||||
// Get gradient color based on percentage
|
||||
function getGradientColor(percentage) {
|
||||
@@ -735,23 +573,6 @@ const selectedStudentsData = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// Activer le mode sélection
|
||||
function activateSelectionMode() {
|
||||
selectionMode.value = true
|
||||
selectedStudents.value = [] // Réinitialiser la sélection
|
||||
}
|
||||
|
||||
// Annuler le mode sélection
|
||||
function cancelSelectionMode() {
|
||||
selectionMode.value = false
|
||||
selectedStudents.value = []
|
||||
}
|
||||
|
||||
// Vider la sélection
|
||||
function clearSelection() {
|
||||
selectedStudents.value = []
|
||||
}
|
||||
|
||||
// Vérifier si un élève est sélectionné
|
||||
function isSelected(studentId) {
|
||||
return selectedStudents.value.includes(studentId)
|
||||
@@ -776,7 +597,6 @@ function openSendModal() {
|
||||
function handleReportsSent(result) {
|
||||
showSendModal.value = false
|
||||
selectedStudents.value = []
|
||||
selectionMode.value = false // Désactiver le mode sélection après envoi
|
||||
// Le modal affiche déjà les résultats, pas besoin de notification supplémentaire
|
||||
}
|
||||
|
||||
@@ -801,3 +621,14 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Hero section -->
|
||||
<div class="bg-gradient-to-r from-success-500 to-emerald-600 rounded-2xl p-8 mb-8 text-white">
|
||||
<h1 class="text-3xl font-bold mb-2">Élèves</h1>
|
||||
<p class="text-success-100">{{ students.length }} élève(s) au total</p>
|
||||
</div>
|
||||
<PageHeader title="Élèves">
|
||||
<template #meta>
|
||||
<span class="badge badge-primary">{{ students.length }}</span>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="card card-body mb-6">
|
||||
@@ -35,7 +35,12 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="student in students" :key="student.id">
|
||||
<tr
|
||||
v-for="student in students"
|
||||
:key="student.id"
|
||||
:class="student.current_class_id ? 'hover:bg-gray-50 cursor-pointer' : ''"
|
||||
@click="student.current_class_id && $router.push(`/classes/${student.current_class_id}/students`)"
|
||||
>
|
||||
<td class="font-medium">{{ student.last_name }}</td>
|
||||
<td>{{ student.first_name }}</td>
|
||||
<td>
|
||||
@@ -65,10 +70,8 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import studentsService from '@/services/students'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
|
||||
// Icons
|
||||
const SearchIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>' }
|
||||
const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import { SearchIcon, AcademicCapIcon } from '@/components/icons'
|
||||
|
||||
const students = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
@@ -22,20 +22,46 @@ export default {
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
},
|
||||
accent: {
|
||||
50: '#faf5ff',
|
||||
100: '#f3e8ff',
|
||||
200: '#e9d5ff',
|
||||
300: '#d8b4fe',
|
||||
400: '#c084fc',
|
||||
500: '#a855f7',
|
||||
600: '#9333ea',
|
||||
700: '#7e22ce',
|
||||
800: '#6b21a8',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
[project]
|
||||
name = "school-management"
|
||||
version = "0.1.0"
|
||||
description = "Application web de gestion scolaire"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"Flask>=2.3.3",
|
||||
"Flask-SQLAlchemy>=3.0.5",
|
||||
"Flask-WTF>=1.1.1",
|
||||
"WTForms>=3.0.1",
|
||||
"python-dotenv>=1.0.0",
|
||||
"pydantic>=2.0.0",
|
||||
"Flask-Mail>=0.9.1",
|
||||
"premailer>=3.10.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["."]
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
"pytest>=7.4.0",
|
||||
"pytest-flask>=1.2.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
]
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
aiosmtpd = "^1.4.6"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"psutil>=7.0.0",
|
||||
]
|
||||
Reference in New Issue
Block a user