Compare commits

...

4 Commits

Author SHA1 Message Date
b8aae00ea7 fix: add scatterplots
All checks were successful
Build and Publish Docker Images / Build Frontend Image (push) Successful in 12m22s
Build and Publish Docker Images / Build Backend Image (push) Successful in 12m31s
Build and Publish Docker Images / Build Summary (push) Successful in 3s
2026-03-02 17:12:33 +01:00
6cca179346 refactor(ui): unify frontend around compact, desktop-first design
All checks were successful
Build and Publish Docker Images / Build Frontend Image (push) Successful in 3m3s
Build and Publish Docker Images / Build Backend Image (push) Successful in 3m14s
Build and Publish Docker Images / Build Summary (push) Successful in 3s
Extract shared utilities (color functions, icon registry), replace hero
banners with compact PageHeader, add TrimesterSelector/ConfirmDialog/
Breadcrumb components, consolidate off-palette colors to design tokens,
convert AssessmentListView to table layout, compress ResultsView stats
into horizontal bar, and inline ClassFormView as a modal in ClassListView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 09:37:46 +01:00
bb15933e69 fix(ux): improve destructive action safety, accents, navigation and feedback
All checks were successful
Build and Publish Docker Images / Build Backend Image (push) Successful in 2m58s
Build and Publish Docker Images / Build Frontend Image (push) Successful in 3m19s
Build and Publish Docker Images / Build Summary (push) Successful in 3s
Replace window.confirm() with Modal dialogs for class deletion and grade
reset. Add unsaved-changes guards to AssessmentFormView. Warn before
deleting exercises/elements with existing grades. Surface invalid grades
in a detailed Modal after save. Replace GradingView local toasts with
global notification store. Fix missing French accents across CouncilView,
CouncilStudentDetail, and ConfigView. Make dashboard "À corriger" card
and student list rows clickable. Add visual hierarchy to assessment
detail actions. Add loading overlay to trimester switch. Simplify email
sending workflow by removing mode-switch pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:25:56 +01:00
a0ab7224e1 refactor: extract duplicated patterns into shared helpers
Backend: create api/helpers.py with eligible_enrollment_filter,
count_eligible_students, get_active_enrollment, ensure_unique_name,
upsert_app_configs, and build_heatmap. Add full_name properties to
Student model. Apply across all route files (-481/+184 lines).

Frontend: create stores/helpers.js with withLoading composable,
apply to assessments and classes Pinia stores.

96/96 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:05:10 +01:00
42 changed files with 1395 additions and 1479 deletions

196
backend/api/helpers.py Normal file
View 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,
)

View File

@@ -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_(
@@ -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,96 +419,24 @@ 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(
@@ -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_(
@@ -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

View File

@@ -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,15 +461,9 @@ 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
)
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à"
await ensure_unique_name(
session, ClassGroup, class_data.name,
exclude_id=class_id, entity_label="classe"
)
# Appliquer les modifications

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,11 +63,7 @@ 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):
@@ -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,7 +479,7 @@ 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
@@ -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,7 +540,7 @@ 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
@@ -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é"
)

View File

@@ -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}>"

View File

@@ -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>

View File

@@ -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 */

View File

@@ -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>

View File

@@ -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>&copy; {{ 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>

View File

@@ -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)

View 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>

View 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>

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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 = {

View 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>

View 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>

View File

@@ -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()

View File

@@ -42,7 +42,7 @@
<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'annee</h3>
<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>
@@ -72,7 +72,7 @@
<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">Evaluations</h3>
<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"
@@ -106,7 +106,7 @@
</div>
</section>
<section v-if="competenceList.length">
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Competences</h3>
<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>
@@ -119,10 +119,10 @@
<script setup>
import { computed } from 'vue'
import { Bar, Chart as ChartGeneric } from 'vue-chartjs'
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend } from 'chart.js'
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, Title, Tooltip, Legend, BoxPlotController, BoxAndWiskers)
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, ScatterController, Title, Tooltip, Legend, BoxPlotController, BoxAndWiskers)
const props = defineProps({
student: { type: Object, required: true },
@@ -182,7 +182,7 @@ const chartData = computed(() => {
labels,
datasets: [
{
label: 'Eleve',
label: 'Élève',
data: studentScores,
backgroundColor: barBgColors,
borderRadius: 3,
@@ -302,7 +302,7 @@ const competenceList = computed(() => {
const meta = lookup[c.competence_id] || {}
return {
id: c.competence_id,
name: meta.name || `Competence ${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
}
@@ -358,7 +358,7 @@ const domainBoxPlotData = computed(() => {
},
{
type: 'scatter',
label: 'Eleve',
label: 'Élève',
data: domains.map((d, i) => ({ x: d.pct, y: i })),
pointRadius: 7,
pointStyle: 'rectRot',
@@ -388,7 +388,7 @@ const competenceBoxPlotData = computed(() => {
},
{
type: 'scatter',
label: 'Eleve',
label: 'Élève',
data: comps.map((c, i) => ({ x: c.pct, y: i })),
pointRadius: 7,
pointStyle: 'rectRot',
@@ -410,7 +410,7 @@ const boxPlotOptions = {
callbacks: {
label(ctx) {
if (ctx.dataset.type === 'scatter') {
return `Eleve: ${ctx.raw.x.toFixed(0)}%`
return `Élève : ${ctx.raw.x.toFixed(0)}%`
}
const item = ctx.raw
return [

View File

@@ -7,7 +7,7 @@
: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-purple-500 focus:border-purple-500"
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" />

View 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>' }

View File

@@ -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',

View File

@@ -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
@@ -44,81 +45,33 @@ export const useAssessmentsStore = defineStore('assessments', () => {
)
// Actions
async function fetchAssessments(customFilters = null) {
loading.value = true
error.value = null
try {
const fetchAssessments = withLoading(loading, error, async (customFilters = null) => {
const queryFilters = customFilters || filters.value
assessments.value = await assessmentsService.getAll(queryFilters)
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
async function fetchAssessment(id) {
loading.value = true
error.value = null
try {
const fetchAssessment = withLoading(loading, error, async (id) => {
currentAssessment.value = await assessmentsService.getById(id)
return currentAssessment.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
async function fetchResults(id) {
loading.value = true
error.value = null
try {
const fetchResults = withLoading(loading, error, async (id) => {
currentResults.value = await assessmentsService.getResults(id)
return currentResults.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
async function fetchGrades(id) {
loading.value = true
error.value = null
try {
const fetchGrades = withLoading(loading, error, async (id) => {
currentGrades.value = await assessmentsService.getGrades(id)
return currentGrades.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
async function createAssessment(data) {
loading.value = true
error.value = null
try {
const createAssessment = withLoading(loading, error, async (data) => {
const newAssessment = await assessmentsService.create(data)
assessments.value.unshift(newAssessment)
return newAssessment
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
async function updateAssessment(id, data) {
loading.value = true
error.value = null
try {
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) {
@@ -128,46 +81,25 @@ export const useAssessmentsStore = defineStore('assessments', () => {
currentAssessment.value = updated
}
return updated
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
async function deleteAssessment(id) {
loading.value = true
error.value = null
try {
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
}
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
async function saveGrades(id, grades) {
loading.value = true
error.value = null
try {
// 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
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
function setFilters(newFilters) {
filters.value = { ...filters.value, ...newFilters }

View File

@@ -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
@@ -17,66 +18,27 @@ export const useClassesStore = defineStore('classes', () => {
)
// Actions
async function fetchClasses() {
loading.value = true
error.value = null
try {
const fetchClasses = withLoading(loading, error, async () => {
classes.value = await classesService.getAll()
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
async function fetchClass(id) {
loading.value = true
error.value = null
try {
const fetchClass = withLoading(loading, error, async (id) => {
currentClass.value = await classesService.getById(id)
return currentClass.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
async function fetchClassStats(id, trimester = null) {
loading.value = true
error.value = null
try {
const fetchClassStats = withLoading(loading, error, async (id, trimester = null) => {
currentStats.value = await classesService.getStats(id, trimester)
return currentStats.value
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
async function createClass(data) {
loading.value = true
error.value = null
try {
const createClass = withLoading(loading, error, async (data) => {
const newClass = await classesService.create(data)
classes.value.push(newClass)
return newClass
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
async function updateClass(id, data) {
loading.value = true
error.value = null
try {
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) {
@@ -86,30 +48,15 @@ export const useClassesStore = defineStore('classes', () => {
currentClass.value = updated
}
return updated
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
async function deleteClass(id) {
loading.value = true
error.value = null
try {
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
}
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
})
function clearCurrent() {
currentClass.value = null

View 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
}
}
}

View 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'
}

View File

@@ -4,29 +4,24 @@
<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
: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>
@@ -50,7 +45,7 @@
<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()

View File

@@ -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"
>
&larr; 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 ?')) {
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() {

View File

@@ -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>
<!-- 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>
</div>
<div>
<label class="label">Statut</label>
<select v-model="filters.status" class="input" @change="applyFilters">
<option value="all">Tous</option>
<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>
</div>
<div>
<label class="label">Tri</label>
<select v-model="filters.sort" class="input" @change="applyFilters">
<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>
</div>
</div>
<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
<!-- 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"
:to="`/assessments/${assessment.id}`"
class="card card-body hover:shadow-md transition-shadow block"
class="border-b border-gray-100 hover:bg-gray-50 transition-colors cursor-pointer"
@click="$router.push(`/assessments/${assessment.id}`)"
>
<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>
<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([

View File

@@ -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">
<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>
</div>
</div>
</div>
</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
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

View File

@@ -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>

View File

@@ -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 -->
@@ -81,12 +82,12 @@
<!-- 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
@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,25 +98,120 @@
</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) {
@@ -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 () => {

View File

@@ -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)
}
}

View File

@@ -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' }
]

View File

@@ -1,29 +1,20 @@
<template>
<div class="px-4 pt-2 pb-1 flex flex-col overflow-hidden" style="height: calc(100vh - 4rem)">
<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">
<router-link :to="`/classes/${classData.id}`" class="text-gray-400 hover:text-gray-600 flex-shrink-0">
<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>
<span class="font-semibold text-gray-900 truncate">{{ classData.name }}</span>
<span class="text-xs text-gray-400 flex-shrink-0">{{ classData.year }}</span>
<div class="flex gap-1 flex-shrink-0">
<button
v-for="t in [1, 2, 3]"
:key="t"
@click="selectTrimester(t)"
class="px-2.5 py-1 rounded text-xs font-medium transition-colors"
:class="trimester === t
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
>
T{{ t }}
</button>
</div>
<TrimesterSelector v-model="trimester" @update:modelValue="selectTrimester" />
<div class="border-l border-gray-200 h-5 flex-shrink-0"></div>
@@ -66,14 +57,14 @@
@next="navigateStudent(1)"
/>
<div v-else class="bg-white rounded-xl shadow-md p-12 text-center">
<p class="text-gray-400">Selectionnez un eleve dans la liste</p>
<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 donnee disponible pour le trimestre {{ trimester }}</p>
<p class="text-gray-500">Aucune donnée disponible pour le trimestre {{ trimester }}</p>
</div>
</template>
</div>
@@ -87,6 +78,8 @@ 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()

View File

@@ -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()

View File

@@ -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"
>
&#9889;
@@ -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)"
/>
@@ -314,8 +310,8 @@
</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"
@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,7 +1034,7 @@ 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)
@@ -1104,7 +1047,12 @@ 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
}
}
@@ -1117,16 +1065,16 @@ 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>

View File

@@ -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>
<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' }]"
/>
<!-- 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>
<!-- 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"
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>

View File

@@ -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)

View File

@@ -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',
}
}
},