Compare commits
4 Commits
b1b7d12a9f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b8aae00ea7 | |||
| 6cca179346 | |||
| bb15933e69 | |||
| a0ab7224e1 |
196
backend/api/helpers.py
Normal file
196
backend/api/helpers.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Fonctions utilitaires partagées entre les routes API.
|
||||
Élimine la duplication de code pour les patterns courants.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from typing import Optional, Dict, List, Tuple, Any, Callable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from infrastructure.database.models import (
|
||||
AppConfig,
|
||||
StudentEnrollment,
|
||||
Student,
|
||||
)
|
||||
from schemas.assessment import HeatmapCell, HeatmapData
|
||||
|
||||
|
||||
def eligible_enrollment_filter(class_group_id: int, at_date: date):
|
||||
"""
|
||||
Retourne les clauses WHERE pour filtrer les inscriptions actives à une date donnée.
|
||||
|
||||
Usage:
|
||||
query = select(StudentEnrollment).where(
|
||||
*eligible_enrollment_filter(class_id, assessment.date)
|
||||
)
|
||||
"""
|
||||
return (
|
||||
StudentEnrollment.class_group_id == class_group_id,
|
||||
StudentEnrollment.enrollment_date <= at_date,
|
||||
(
|
||||
StudentEnrollment.departure_date.is_(None)
|
||||
| (StudentEnrollment.departure_date >= at_date)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def count_eligible_students(
|
||||
session: AsyncSession, class_group_id: int, at_date: date
|
||||
) -> int:
|
||||
"""Compte les élèves inscrits dans une classe à une date donnée."""
|
||||
query = select(func.count(StudentEnrollment.id)).where(
|
||||
*eligible_enrollment_filter(class_group_id, at_date)
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
def eligible_student_ids_subquery(class_group_id: int, at_date: date):
|
||||
"""Retourne une sous-requête des student_id éligibles à une date donnée."""
|
||||
return select(StudentEnrollment.student_id).where(
|
||||
*eligible_enrollment_filter(class_group_id, at_date)
|
||||
)
|
||||
|
||||
|
||||
async def get_eligible_enrollments(
|
||||
session: AsyncSession, class_group_id: int, at_date: date
|
||||
):
|
||||
"""Récupère les inscriptions éligibles avec les étudiants chargés."""
|
||||
query = (
|
||||
select(StudentEnrollment)
|
||||
.options(selectinload(StudentEnrollment.student))
|
||||
.where(*eligible_enrollment_filter(class_group_id, at_date))
|
||||
)
|
||||
result = await session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
def get_active_enrollment(
|
||||
student: Student,
|
||||
) -> Optional[StudentEnrollment]:
|
||||
"""Retourne l'inscription active (sans date de départ) d'un élève."""
|
||||
for enrollment in student.enrollments:
|
||||
if enrollment.departure_date is None:
|
||||
return enrollment
|
||||
return None
|
||||
|
||||
|
||||
async def ensure_unique_name(
|
||||
session: AsyncSession,
|
||||
model_class,
|
||||
name: str,
|
||||
*,
|
||||
field_name: str = "name",
|
||||
exclude_id: Optional[int] = None,
|
||||
entity_label: str = "enregistrement",
|
||||
):
|
||||
"""
|
||||
Vérifie qu'un nom est unique pour un modèle donné.
|
||||
Lève HTTPException 400 si un doublon est trouvé.
|
||||
"""
|
||||
field = getattr(model_class, field_name)
|
||||
query = select(model_class).where(field == name)
|
||||
|
||||
if exclude_id is not None:
|
||||
query = query.where(model_class.id != exclude_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Un(e) {entity_label} avec le nom '{name}' existe déjà",
|
||||
)
|
||||
|
||||
|
||||
async def upsert_app_configs(
|
||||
session: AsyncSession, updates: Dict[str, str]
|
||||
) -> None:
|
||||
"""
|
||||
Met à jour ou crée des entrées AppConfig en lot.
|
||||
Ne fait PAS de commit — l'appelant doit appeler session.commit().
|
||||
"""
|
||||
for key, value in updates.items():
|
||||
query = select(AppConfig).where(AppConfig.key == key)
|
||||
result = await session.execute(query)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config:
|
||||
config.value = value
|
||||
else:
|
||||
session.add(AppConfig(key=key, value=value))
|
||||
|
||||
|
||||
def build_heatmap(
|
||||
enrollments,
|
||||
assessment,
|
||||
items: dict,
|
||||
item_extractor: Callable,
|
||||
grading_calc,
|
||||
sorted_student_names: List[str],
|
||||
*,
|
||||
color_map: Optional[Dict[str, str]] = None,
|
||||
) -> Optional[HeatmapData]:
|
||||
"""
|
||||
Construit une HeatmapData pour des compétences ou domaines.
|
||||
|
||||
Args:
|
||||
enrollments: liste d'inscriptions avec .student chargé
|
||||
assessment: l'évaluation avec .exercises chargés
|
||||
items: set ou dict des items à évaluer (noms)
|
||||
item_extractor: fonction (element) -> nom de l'item ou None
|
||||
grading_calc: instance de GradingCalculator
|
||||
sorted_student_names: noms triés pour l'axe Y
|
||||
color_map: optionnel, dict nom -> couleur pour les cellules
|
||||
"""
|
||||
if not items:
|
||||
return None
|
||||
|
||||
item_names = set(items) if isinstance(items, dict) else items
|
||||
cells = []
|
||||
|
||||
for enrollment in enrollments:
|
||||
student = enrollment.student
|
||||
student_name = student.full_name_reversed
|
||||
|
||||
item_scores = {name: {"score": 0.0, "max": 0.0} for name in item_names}
|
||||
|
||||
for exercise in assessment.exercises:
|
||||
for element in exercise.grading_elements:
|
||||
item_name = item_extractor(element)
|
||||
if item_name and item_name in item_scores:
|
||||
for g in element.grades:
|
||||
if g.student_id == student.id and g.value:
|
||||
calc_score = grading_calc.calculate_score(
|
||||
g.value.strip(),
|
||||
element.grading_type,
|
||||
element.max_points,
|
||||
)
|
||||
if calc_score is not None:
|
||||
item_scores[item_name]["score"] += calc_score
|
||||
item_scores[item_name]["max"] += element.max_points
|
||||
break
|
||||
|
||||
for name, data in item_scores.items():
|
||||
if data["max"] > 0:
|
||||
pct = round((data["score"] / data["max"]) * 100, 1)
|
||||
cell_kwargs = dict(
|
||||
student_id=student.id,
|
||||
student_name=student_name,
|
||||
item_name=name,
|
||||
score=round(data["score"], 2),
|
||||
max_points=data["max"],
|
||||
percentage=pct,
|
||||
)
|
||||
if color_map and name in color_map:
|
||||
cell_kwargs["color"] = color_map[name]
|
||||
cells.append(HeatmapCell(**cell_kwargs))
|
||||
|
||||
return HeatmapData(
|
||||
items=sorted(list(item_names)),
|
||||
students=sorted_student_names,
|
||||
cells=cells,
|
||||
)
|
||||
@@ -11,6 +11,12 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from api.dependencies import AsyncSessionDep
|
||||
from api.helpers import (
|
||||
count_eligible_students,
|
||||
eligible_student_ids_subquery,
|
||||
get_eligible_enrollments,
|
||||
build_heatmap,
|
||||
)
|
||||
from infrastructure.database.models import (
|
||||
Assessment,
|
||||
Exercise,
|
||||
@@ -36,8 +42,6 @@ from schemas.assessment import (
|
||||
SendReportsRequest,
|
||||
SendReportResult,
|
||||
SendReportsResponse,
|
||||
HeatmapCell,
|
||||
HeatmapData,
|
||||
)
|
||||
from schemas.grading import BulkGradeCreate, BulkGradeResponse, GradeRead
|
||||
from domain.services import GradingCalculator, StatisticsService, StudentReportService, generate_report_html, ConfigService
|
||||
@@ -124,21 +128,13 @@ async def get_assessments(
|
||||
total_elements += 1
|
||||
|
||||
# Compter les élèves éligibles (inscrits à la date de l'évaluation)
|
||||
eligible_query = select(func.count(StudentEnrollment.id)).where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
eligible_students_count = await count_eligible_students(
|
||||
session, assessment.class_group_id, assessment.date
|
||||
)
|
||||
eligible_result = await session.execute(eligible_query)
|
||||
eligible_students_count = eligible_result.scalar() or 0
|
||||
|
||||
|
||||
# Compter les notes saisies uniquement pour les élèves éligibles
|
||||
eligible_student_ids = select(StudentEnrollment.student_id).where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
eligible_student_ids = eligible_student_ids_subquery(
|
||||
assessment.class_group_id, assessment.date
|
||||
)
|
||||
grades_query = select(func.count(Grade.id)).where(
|
||||
Grade.grading_element_id.in_(
|
||||
@@ -239,21 +235,13 @@ async def get_assessment(
|
||||
)
|
||||
|
||||
# Calculer la progression
|
||||
eligible_query = select(func.count(StudentEnrollment.id)).where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
eligible_students_count = await count_eligible_students(
|
||||
session, assessment.class_group_id, assessment.date
|
||||
)
|
||||
eligible_result = await session.execute(eligible_query)
|
||||
eligible_students_count = eligible_result.scalar() or 0
|
||||
|
||||
|
||||
# Compter les notes uniquement pour les élèves éligibles
|
||||
eligible_student_ids = select(StudentEnrollment.student_id).where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
eligible_student_ids = eligible_student_ids_subquery(
|
||||
assessment.class_group_id, assessment.date
|
||||
)
|
||||
grades_query = select(func.count(Grade.id)).where(
|
||||
Grade.grading_element_id.in_(
|
||||
@@ -266,11 +254,11 @@ async def get_assessment(
|
||||
)
|
||||
grades_result = await session.execute(grades_query)
|
||||
grades_count = grades_result.scalar() or 0
|
||||
|
||||
|
||||
progress = calculate_grading_progress(
|
||||
assessment, grades_count, total_elements, eligible_students_count
|
||||
)
|
||||
|
||||
|
||||
return AssessmentDetail(
|
||||
id=assessment.id,
|
||||
title=assessment.title,
|
||||
@@ -322,18 +310,9 @@ async def get_assessment_results(
|
||||
raise HTTPException(status_code=404, detail="Évaluation non trouvée")
|
||||
|
||||
# Récupérer les élèves éligibles
|
||||
eligible_query = (
|
||||
select(StudentEnrollment)
|
||||
.options(selectinload(StudentEnrollment.student))
|
||||
.where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
)
|
||||
enrollments = await get_eligible_enrollments(
|
||||
session, assessment.class_group_id, assessment.date
|
||||
)
|
||||
eligible_result = await session.execute(eligible_query)
|
||||
enrollments = eligible_result.scalars().all()
|
||||
|
||||
# Calculer le total des points maximum
|
||||
total_max_points = 0
|
||||
@@ -392,7 +371,7 @@ async def get_assessment_results(
|
||||
|
||||
student_scores[student_id] = StudentScore(
|
||||
student_id=student_id,
|
||||
student_name=f"{student.last_name} {student.first_name}",
|
||||
student_name=student.full_name_reversed,
|
||||
email=student.email, # Ajouter l'email de l'élève
|
||||
total_score=round(total_score, 2),
|
||||
total_max_points=counted_max,
|
||||
@@ -440,97 +419,25 @@ async def get_assessment_results(
|
||||
all_domains[element.domain.name] = element.domain.color
|
||||
|
||||
# Calculer heatmap des compétences si présentes
|
||||
if all_competences:
|
||||
competences_cells = []
|
||||
|
||||
for enrollment in enrollments:
|
||||
student = enrollment.student
|
||||
student_name = f"{student.last_name} {student.first_name}"
|
||||
|
||||
# Calculer score par compétence pour cet élève
|
||||
competence_scores = {}
|
||||
for comp in all_competences:
|
||||
competence_scores[comp] = {"score": 0.0, "max": 0.0}
|
||||
|
||||
for exercise in assessment.exercises:
|
||||
for element in exercise.grading_elements:
|
||||
if element.skill and element.skill in competence_scores:
|
||||
# Trouver la note
|
||||
for g in element.grades:
|
||||
if g.student_id == student.id and g.value:
|
||||
calc_score = grading_calc.calculate_score(
|
||||
g.value.strip(), element.grading_type, element.max_points
|
||||
)
|
||||
if calc_score is not None:
|
||||
competence_scores[element.skill]["score"] += calc_score
|
||||
competence_scores[element.skill]["max"] += element.max_points
|
||||
break
|
||||
|
||||
# Créer les cellules
|
||||
for comp, data in competence_scores.items():
|
||||
if data["max"] > 0:
|
||||
pct = round((data["score"] / data["max"]) * 100, 1)
|
||||
competences_cells.append(HeatmapCell(
|
||||
student_id=student.id,
|
||||
student_name=student_name,
|
||||
item_name=comp,
|
||||
score=round(data["score"], 2),
|
||||
max_points=data["max"],
|
||||
percentage=pct
|
||||
))
|
||||
|
||||
competences_heatmap = HeatmapData(
|
||||
items=sorted(list(all_competences)),
|
||||
students=[s.student_name for s in sorted_scores],
|
||||
cells=competences_cells
|
||||
)
|
||||
competences_heatmap = build_heatmap(
|
||||
enrollments=enrollments,
|
||||
assessment=assessment,
|
||||
items=all_competences,
|
||||
item_extractor=lambda el: el.skill,
|
||||
grading_calc=grading_calc,
|
||||
sorted_student_names=[s.student_name for s in sorted_scores],
|
||||
)
|
||||
|
||||
# Calculer heatmap des domaines si présents
|
||||
if all_domains:
|
||||
domains_cells = []
|
||||
|
||||
for enrollment in enrollments:
|
||||
student = enrollment.student
|
||||
student_name = f"{student.last_name} {student.first_name}"
|
||||
|
||||
# Calculer score par domaine pour cet élève
|
||||
domain_scores = {}
|
||||
for dom in all_domains:
|
||||
domain_scores[dom] = {"score": 0.0, "max": 0.0}
|
||||
|
||||
for exercise in assessment.exercises:
|
||||
for element in exercise.grading_elements:
|
||||
if element.domain and element.domain.name in domain_scores:
|
||||
# Trouver la note
|
||||
for g in element.grades:
|
||||
if g.student_id == student.id and g.value:
|
||||
calc_score = grading_calc.calculate_score(
|
||||
g.value.strip(), element.grading_type, element.max_points
|
||||
)
|
||||
if calc_score is not None:
|
||||
domain_scores[element.domain.name]["score"] += calc_score
|
||||
domain_scores[element.domain.name]["max"] += element.max_points
|
||||
break
|
||||
|
||||
# Créer les cellules
|
||||
for dom, data in domain_scores.items():
|
||||
if data["max"] > 0:
|
||||
pct = round((data["score"] / data["max"]) * 100, 1)
|
||||
domains_cells.append(HeatmapCell(
|
||||
student_id=student.id,
|
||||
student_name=student_name,
|
||||
item_name=dom,
|
||||
score=round(data["score"], 2),
|
||||
max_points=data["max"],
|
||||
percentage=pct,
|
||||
color=all_domains.get(dom)
|
||||
))
|
||||
|
||||
domains_heatmap = HeatmapData(
|
||||
items=sorted(list(all_domains.keys())),
|
||||
students=[s.student_name for s in sorted_scores],
|
||||
cells=domains_cells
|
||||
)
|
||||
domains_heatmap = build_heatmap(
|
||||
enrollments=enrollments,
|
||||
assessment=assessment,
|
||||
items=all_domains,
|
||||
item_extractor=lambda el: el.domain.name if el.domain else None,
|
||||
grading_calc=grading_calc,
|
||||
sorted_student_names=[s.student_name for s in sorted_scores],
|
||||
color_map=all_domains,
|
||||
)
|
||||
|
||||
return AssessmentResults(
|
||||
assessment_id=assessment.id,
|
||||
@@ -827,21 +734,13 @@ async def update_assessment(
|
||||
)
|
||||
|
||||
# Calculer la progression
|
||||
eligible_query = select(func.count(StudentEnrollment.id)).where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
eligible_students_count = await count_eligible_students(
|
||||
session, assessment.class_group_id, assessment.date
|
||||
)
|
||||
eligible_result = await session.execute(eligible_query)
|
||||
eligible_students_count = eligible_result.scalar() or 0
|
||||
|
||||
|
||||
# Compter les notes uniquement pour les élèves éligibles
|
||||
eligible_student_ids = select(StudentEnrollment.student_id).where(
|
||||
StudentEnrollment.class_group_id == assessment.class_group_id,
|
||||
StudentEnrollment.enrollment_date <= assessment.date,
|
||||
(StudentEnrollment.departure_date.is_(None) |
|
||||
(StudentEnrollment.departure_date >= assessment.date))
|
||||
eligible_student_ids = eligible_student_ids_subquery(
|
||||
assessment.class_group_id, assessment.date
|
||||
)
|
||||
grades_query = select(func.count(Grade.id)).where(
|
||||
Grade.grading_element_id.in_(
|
||||
@@ -854,11 +753,11 @@ async def update_assessment(
|
||||
)
|
||||
grades_result = await session.execute(grades_query)
|
||||
grades_count = grades_result.scalar() or 0
|
||||
|
||||
|
||||
progress = calculate_grading_progress(
|
||||
assessment, grades_count, total_elements, eligible_students_count
|
||||
)
|
||||
|
||||
|
||||
return AssessmentDetail(
|
||||
id=assessment.id,
|
||||
title=assessment.title,
|
||||
@@ -1132,7 +1031,7 @@ async def send_reports(
|
||||
total_failed = 0
|
||||
|
||||
for student in students:
|
||||
student_name = f"{student.first_name} {student.last_name}"
|
||||
student_name = student.full_name
|
||||
|
||||
# Vérifier que l'élève a une adresse email
|
||||
if not student.email:
|
||||
@@ -1307,7 +1206,7 @@ async def preview_report(
|
||||
if student_id not in all_students_grades:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"L'élève {student.first_name} {student.last_name} n'a pas de notes pour cette évaluation"
|
||||
detail=f"L'élève {student.full_name} n'a pas de notes pour cette évaluation"
|
||||
)
|
||||
|
||||
# Créer le service de rapport
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from api.dependencies import AsyncSessionDep
|
||||
from api.helpers import ensure_unique_name
|
||||
from infrastructure.database.models import (
|
||||
ClassGroup,
|
||||
Student,
|
||||
@@ -201,7 +202,7 @@ async def get_class_students(
|
||||
last_name=student.last_name,
|
||||
first_name=student.first_name,
|
||||
email=student.email,
|
||||
full_name=f"{student.first_name} {student.last_name}",
|
||||
full_name=student.full_name,
|
||||
current_class_id=class_id if is_active else None,
|
||||
current_class_name=cls.name if is_active else None,
|
||||
enrollment_id=enrollment.id,
|
||||
@@ -419,13 +420,7 @@ async def create_class(
|
||||
Crée une nouvelle classe.
|
||||
"""
|
||||
# Vérifier l'unicité du nom
|
||||
existing_query = select(ClassGroup).where(ClassGroup.name == class_data.name)
|
||||
existing_result = await session.execute(existing_query)
|
||||
if existing_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Une classe avec le nom '{class_data.name}' existe déjà"
|
||||
)
|
||||
await ensure_unique_name(session, ClassGroup, class_data.name, entity_label="classe")
|
||||
|
||||
# Créer la nouvelle classe
|
||||
new_class = ClassGroup(
|
||||
@@ -466,16 +461,10 @@ async def update_class(
|
||||
|
||||
# Vérifier l'unicité du nouveau nom si modifié
|
||||
if class_data.name and class_data.name != cls.name:
|
||||
existing_query = select(ClassGroup).where(
|
||||
ClassGroup.name == class_data.name,
|
||||
ClassGroup.id != class_id
|
||||
await ensure_unique_name(
|
||||
session, ClassGroup, class_data.name,
|
||||
exclude_id=class_id, entity_label="classe"
|
||||
)
|
||||
existing_result = await session.execute(existing_query)
|
||||
if existing_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Une autre classe avec le nom '{class_data.name}' existe déjà"
|
||||
)
|
||||
|
||||
# Appliquer les modifications
|
||||
if class_data.name is not None:
|
||||
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException
|
||||
from sqlalchemy import select, func, delete
|
||||
|
||||
from api.dependencies import AsyncSessionDep
|
||||
from api.helpers import ensure_unique_name, upsert_app_configs
|
||||
from infrastructure.database.models import (
|
||||
AppConfig,
|
||||
Competence,
|
||||
@@ -228,13 +229,7 @@ async def create_competence(
|
||||
Crée une nouvelle compétence.
|
||||
"""
|
||||
# Vérifier l'unicité du nom
|
||||
existing_query = select(Competence).where(Competence.name == data.name)
|
||||
existing_result = await session.execute(existing_query)
|
||||
if existing_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Une compétence avec le nom '{data.name}' existe déjà"
|
||||
)
|
||||
await ensure_unique_name(session, Competence, data.name, entity_label="compétence")
|
||||
|
||||
# Déterminer l'index d'ordre
|
||||
if data.order_index is None:
|
||||
@@ -284,16 +279,7 @@ async def update_competence(
|
||||
|
||||
# Vérifier l'unicité du nouveau nom
|
||||
if data.name and data.name != competence.name:
|
||||
existing_query = select(Competence).where(
|
||||
Competence.name == data.name,
|
||||
Competence.id != competence_id
|
||||
)
|
||||
existing_result = await session.execute(existing_query)
|
||||
if existing_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Une autre compétence avec le nom '{data.name}' existe déjà"
|
||||
)
|
||||
await ensure_unique_name(session, Competence, data.name, exclude_id=competence_id, entity_label="compétence")
|
||||
|
||||
# Appliquer les modifications
|
||||
if data.name is not None:
|
||||
@@ -353,13 +339,7 @@ async def create_domain(
|
||||
Crée un nouveau domaine.
|
||||
"""
|
||||
# Vérifier l'unicité du nom
|
||||
existing_query = select(Domain).where(Domain.name == data.name)
|
||||
existing_result = await session.execute(existing_query)
|
||||
if existing_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Un domaine avec le nom '{data.name}' existe déjà"
|
||||
)
|
||||
await ensure_unique_name(session, Domain, data.name, entity_label="domaine")
|
||||
|
||||
# Créer le domaine
|
||||
domain = Domain(
|
||||
@@ -398,16 +378,7 @@ async def update_domain(
|
||||
|
||||
# Vérifier l'unicité du nouveau nom
|
||||
if data.name and data.name != domain.name:
|
||||
existing_query = select(Domain).where(
|
||||
Domain.name == data.name,
|
||||
Domain.id != domain_id
|
||||
)
|
||||
existing_result = await session.execute(existing_query)
|
||||
if existing_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Un autre domaine avec le nom '{data.name}' existe déjà"
|
||||
)
|
||||
await ensure_unique_name(session, Domain, data.name, exclude_id=domain_id, entity_label="domaine")
|
||||
|
||||
# Appliquer les modifications
|
||||
if data.name is not None:
|
||||
@@ -669,18 +640,7 @@ async def update_app_config(
|
||||
if data.default_grading_system is not None:
|
||||
updates.append(("default_grading_system", data.default_grading_system))
|
||||
|
||||
for key, value in updates:
|
||||
# Chercher la config existante
|
||||
query = select(AppConfig).where(AppConfig.key == key)
|
||||
result = await session.execute(query)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config:
|
||||
config.value = value
|
||||
else:
|
||||
# Créer si n'existe pas
|
||||
new_config = AppConfig(key=key, value=value)
|
||||
session.add(new_config)
|
||||
await upsert_app_configs(session, dict(updates))
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -755,18 +715,7 @@ async def update_smtp_config(
|
||||
if data.from_address is not None:
|
||||
updates.append(("email.from_address", data.from_address))
|
||||
|
||||
for key, value in updates:
|
||||
# Chercher la config existante
|
||||
query = select(AppConfig).where(AppConfig.key == key)
|
||||
result = await session.execute(query)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config:
|
||||
config.value = value
|
||||
else:
|
||||
# Créer si n'existe pas
|
||||
new_config = AppConfig(key=key, value=value)
|
||||
session.add(new_config)
|
||||
await upsert_app_configs(session, dict(updates))
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -843,16 +792,7 @@ async def update_notes_gradient(
|
||||
if data.enabled is not None:
|
||||
updates.append(("grading.notes_gradient.enabled", "true" if data.enabled else "false"))
|
||||
|
||||
for key, value in updates:
|
||||
query = select(AppConfig).where(AppConfig.key == key)
|
||||
result = await session.execute(query)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if config:
|
||||
config.value = value
|
||||
else:
|
||||
new_config = AppConfig(key=key, value=value)
|
||||
session.add(new_config)
|
||||
await upsert_app_configs(session, dict(updates))
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ async def get_council_preparation(
|
||||
student_id=student.id,
|
||||
first_name=student.first_name,
|
||||
last_name=student.last_name,
|
||||
full_name=f"{student.first_name} {student.last_name}",
|
||||
full_name=student.full_name,
|
||||
overall_average=overall_average,
|
||||
assessment_count=assessment_count,
|
||||
grades_by_assessment=grades_by_assessment,
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from api.dependencies import AsyncSessionDep
|
||||
from api.helpers import get_active_enrollment
|
||||
from infrastructure.database.models import (
|
||||
Student,
|
||||
StudentEnrollment,
|
||||
@@ -62,12 +63,8 @@ async def get_students(
|
||||
students_list = []
|
||||
for student in students:
|
||||
# Trouver l'inscription active
|
||||
current_enrollment = None
|
||||
for enrollment in student.enrollments:
|
||||
if enrollment.departure_date is None:
|
||||
current_enrollment = enrollment
|
||||
break
|
||||
|
||||
current_enrollment = get_active_enrollment(student)
|
||||
|
||||
# Filtrer par classe si demandé
|
||||
if class_id and (not current_enrollment or current_enrollment.class_group_id != class_id):
|
||||
continue
|
||||
@@ -78,7 +75,7 @@ async def get_students(
|
||||
last_name=student.last_name,
|
||||
first_name=student.first_name,
|
||||
email=student.email,
|
||||
full_name=f"{student.first_name} {student.last_name}",
|
||||
full_name=student.full_name,
|
||||
current_class_id=current_enrollment.class_group_id if current_enrollment else None,
|
||||
current_class_name=current_enrollment.class_group.name if current_enrollment else None
|
||||
)
|
||||
@@ -113,13 +110,10 @@ async def get_student(
|
||||
raise HTTPException(status_code=404, detail="Étudiant non trouvé")
|
||||
|
||||
# Trouver l'inscription active
|
||||
current_enrollment = None
|
||||
current_enrollment = get_active_enrollment(student)
|
||||
enrollments_list = []
|
||||
|
||||
|
||||
for enrollment in student.enrollments:
|
||||
if enrollment.departure_date is None:
|
||||
current_enrollment = enrollment
|
||||
|
||||
enrollments_list.append(
|
||||
EnrollmentRead(
|
||||
id=enrollment.id,
|
||||
@@ -144,7 +138,7 @@ async def get_student(
|
||||
last_name=student.last_name,
|
||||
first_name=student.first_name,
|
||||
email=student.email,
|
||||
full_name=f"{student.first_name} {student.last_name}",
|
||||
full_name=student.full_name,
|
||||
current_class_id=current_enrollment.class_group_id if current_enrollment else None,
|
||||
current_class_name=current_enrollment.class_group.name if current_enrollment else None,
|
||||
enrollments=enrollments_list
|
||||
@@ -210,7 +204,7 @@ async def create_student(
|
||||
last_name=new_student.last_name,
|
||||
first_name=new_student.first_name,
|
||||
email=new_student.email,
|
||||
full_name=f"{new_student.first_name} {new_student.last_name}",
|
||||
full_name=new_student.full_name,
|
||||
current_class_id=current_class_id,
|
||||
current_class_name=current_class_name
|
||||
)
|
||||
@@ -264,18 +258,14 @@ async def update_student(
|
||||
await session.refresh(student)
|
||||
|
||||
# Trouver la classe actuelle
|
||||
current_enrollment = None
|
||||
for enrollment in student.enrollments:
|
||||
if enrollment.departure_date is None:
|
||||
current_enrollment = enrollment
|
||||
break
|
||||
|
||||
current_enrollment = get_active_enrollment(student)
|
||||
|
||||
return StudentWithClass(
|
||||
id=student.id,
|
||||
last_name=student.last_name,
|
||||
first_name=student.first_name,
|
||||
email=student.email,
|
||||
full_name=f"{student.first_name} {student.last_name}",
|
||||
full_name=student.full_name,
|
||||
current_class_id=current_enrollment.class_group_id if current_enrollment else None,
|
||||
current_class_name=current_enrollment.class_group.name if current_enrollment else None
|
||||
)
|
||||
@@ -325,18 +315,14 @@ async def update_student_email(
|
||||
await session.refresh(student)
|
||||
|
||||
# Trouver la classe actuelle
|
||||
current_enrollment = None
|
||||
for enrollment in student.enrollments:
|
||||
if enrollment.departure_date is None:
|
||||
current_enrollment = enrollment
|
||||
break
|
||||
|
||||
current_enrollment = get_active_enrollment(student)
|
||||
|
||||
return StudentWithClass(
|
||||
id=student.id,
|
||||
last_name=student.last_name,
|
||||
first_name=student.first_name,
|
||||
email=student.email,
|
||||
full_name=f"{student.first_name} {student.last_name}",
|
||||
full_name=student.full_name,
|
||||
current_class_id=current_enrollment.class_group_id if current_enrollment else None,
|
||||
current_class_name=current_enrollment.class_group.name if current_enrollment else None
|
||||
)
|
||||
@@ -403,7 +389,7 @@ async def enroll_student(
|
||||
if active_result.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"L'élève {student.first_name} {student.last_name} est déjà inscrit dans une classe"
|
||||
detail=f"L'élève {student.full_name} est déjà inscrit dans une classe"
|
||||
)
|
||||
else:
|
||||
# Nouvel élève
|
||||
@@ -447,9 +433,9 @@ async def enroll_student(
|
||||
return EnrollmentResponse(
|
||||
enrollment_id=enrollment.id,
|
||||
student_id=student.id,
|
||||
student_name=f"{student.first_name} {student.last_name}",
|
||||
student_name=student.full_name,
|
||||
class_name=class_group.name,
|
||||
message=f"Élève {student.first_name} {student.last_name} inscrit en {class_group.name}",
|
||||
message=f"Élève {student.full_name} inscrit en {class_group.name}",
|
||||
is_new_student=is_new_student
|
||||
)
|
||||
|
||||
@@ -493,9 +479,9 @@ async def transfer_student(
|
||||
if not old_enrollment:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Aucune inscription active trouvée pour l'élève {student.first_name} {student.last_name}"
|
||||
detail=f"Aucune inscription active trouvée pour l'élève {student.full_name}"
|
||||
)
|
||||
|
||||
|
||||
old_class_name = old_enrollment.class_group.name
|
||||
|
||||
# Terminer l'ancienne inscription
|
||||
@@ -516,10 +502,10 @@ async def transfer_student(
|
||||
return TransferResponse(
|
||||
old_enrollment_id=old_enrollment.id,
|
||||
new_enrollment_id=new_enrollment.id,
|
||||
student_name=f"{student.first_name} {student.last_name}",
|
||||
student_name=student.full_name,
|
||||
old_class_name=old_class_name,
|
||||
new_class_name=new_class.name,
|
||||
message=f"Élève {student.first_name} {student.last_name} transféré de {old_class_name} vers {new_class.name}"
|
||||
message=f"Élève {student.full_name} transféré de {old_class_name} vers {new_class.name}"
|
||||
)
|
||||
|
||||
|
||||
@@ -554,9 +540,9 @@ async def record_departure(
|
||||
if not enrollment:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Aucune inscription active trouvée pour l'élève {student.first_name} {student.last_name}"
|
||||
detail=f"Aucune inscription active trouvée pour l'élève {student.full_name}"
|
||||
)
|
||||
|
||||
|
||||
class_name = enrollment.class_group.name
|
||||
|
||||
# Enregistrer le départ
|
||||
@@ -567,7 +553,7 @@ async def record_departure(
|
||||
|
||||
return DepartureResponse(
|
||||
enrollment_id=enrollment.id,
|
||||
student_name=f"{student.first_name} {student.last_name}",
|
||||
student_name=student.full_name,
|
||||
class_name=class_name,
|
||||
message=f"Départ de {student.first_name} {student.last_name} de {class_name} enregistré"
|
||||
message=f"Départ de {student.full_name} de {class_name} enregistré"
|
||||
)
|
||||
|
||||
@@ -123,6 +123,16 @@ class Student(Base):
|
||||
"CouncilAppreciation", back_populates="student", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
"""Prénom Nom"""
|
||||
return f"{self.first_name} {self.last_name}"
|
||||
|
||||
@property
|
||||
def full_name_reversed(self) -> str:
|
||||
"""Nom Prénom"""
|
||||
return f"{self.last_name} {self.first_name}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Student {self.first_name} {self.last_name}>"
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<div class="h-screen flex flex-col overflow-hidden">
|
||||
<AppHeader />
|
||||
<main class="flex-1">
|
||||
<main class="flex-1 overflow-auto">
|
||||
<router-view />
|
||||
</main>
|
||||
<AppFooter />
|
||||
<NotificationContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppHeader from '@/components/common/AppHeader.vue'
|
||||
import AppFooter from '@/components/common/AppFooter.vue'
|
||||
import NotificationContainer from '@/components/common/NotificationContainer.vue'
|
||||
</script>
|
||||
|
||||
@@ -106,6 +106,14 @@
|
||||
.table tbody tr {
|
||||
@apply hover:bg-gray-50 transition-colors;
|
||||
}
|
||||
|
||||
.btn-modal-cancel {
|
||||
@apply px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50;
|
||||
}
|
||||
|
||||
.btn-modal-confirm {
|
||||
@apply px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700;
|
||||
}
|
||||
}
|
||||
|
||||
/* Progress indicator colors */
|
||||
|
||||
@@ -36,14 +36,14 @@
|
||||
<!-- Create new option -->
|
||||
<div
|
||||
v-if="canCreate"
|
||||
class="px-3 py-2 text-sm cursor-pointer hover:bg-purple-100 border-t border-gray-200 flex items-center"
|
||||
:class="{ 'bg-purple-100': selectedIndex === suggestions.length }"
|
||||
class="px-3 py-2 text-sm cursor-pointer hover:bg-accent-100 border-t border-gray-200 flex items-center"
|
||||
:class="{ 'bg-accent-100': selectedIndex === suggestions.length }"
|
||||
@mousedown.prevent="openCreateModal"
|
||||
>
|
||||
<svg class="w-4 h-4 text-purple-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg class="w-4 h-4 text-accent-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span class="text-purple-600 font-medium">Créer "{{ searchQuery }}"</span>
|
||||
<span class="text-accent-600 font-medium">Créer "{{ searchQuery }}"</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
:key="color"
|
||||
type="button"
|
||||
class="w-8 h-8 rounded-full border-2 transition-all"
|
||||
:class="newDomain.color === color ? 'ring-2 ring-purple-500 border-white' : 'border-gray-300 hover:border-gray-400'"
|
||||
:class="newDomain.color === color ? 'ring-2 ring-accent-500 border-white' : 'border-gray-300 hover:border-gray-400'"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="newDomain.color = color"
|
||||
></button>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center text-sm text-gray-500">
|
||||
<p>© {{ currentYear }} Notytex - Gestion Scolaire</p>
|
||||
<p class="mt-2 sm:mt-0">Version 2.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const currentYear = new Date().getFullYear()
|
||||
</script>
|
||||
@@ -66,15 +66,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
// Simple icon components
|
||||
const HomeIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /></svg>' }
|
||||
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
|
||||
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
|
||||
const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
|
||||
const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
|
||||
const MenuIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>' }
|
||||
const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
|
||||
import { HomeIcon, UsersIcon, ClipboardIcon, AcademicCapIcon, CogIcon, MenuIcon, XIcon } from '@/components/icons'
|
||||
|
||||
const route = useRoute()
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
23
frontend/src/components/common/Breadcrumb.vue
Normal file
23
frontend/src/components/common/Breadcrumb.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<nav class="flex items-center text-sm text-gray-500 mb-1" aria-label="Breadcrumb">
|
||||
<template v-for="(crumb, index) in crumbs" :key="index">
|
||||
<router-link v-if="crumb.to" :to="crumb.to" class="hover:text-gray-700 transition-colors">
|
||||
{{ crumb.label }}
|
||||
</router-link>
|
||||
<span v-else class="text-gray-900 font-medium">{{ crumb.label }}</span>
|
||||
<svg v-if="index < crumbs.length - 1" class="w-4 h-4 mx-1.5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
crumbs: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: (v) => v.every(c => c.label)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
73
frontend/src/components/common/ConfirmDialog.vue
Normal file
73
frontend/src/components/common/ConfirmDialog.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<Modal v-model="visible" :title="title" size="sm">
|
||||
<p class="text-gray-600">{{ message }}</p>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="cancel" class="btn-modal-cancel">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="confirm"
|
||||
class="px-4 py-2 text-sm font-medium text-white rounded-md"
|
||||
:class="confirmClasses"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Modal from './Modal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Confirmer'
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: 'Êtes-vous sûr ?'
|
||||
},
|
||||
confirmLabel: {
|
||||
type: String,
|
||||
default: 'Confirmer'
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'danger',
|
||||
validator: (v) => ['danger', 'primary', 'warning'].includes(v)
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const confirmClasses = computed(() => {
|
||||
const map = {
|
||||
danger: 'bg-danger-600 hover:bg-danger-700',
|
||||
primary: 'bg-primary-600 hover:bg-primary-700',
|
||||
warning: 'bg-warning-600 hover:bg-warning-700'
|
||||
}
|
||||
return map[props.variant]
|
||||
})
|
||||
|
||||
function cancel() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { FolderIcon, UsersIcon, ClipboardIcon, ChartIcon, SearchIcon } from '@/components/icons'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
@@ -58,13 +59,6 @@ const props = defineProps({
|
||||
|
||||
defineEmits(['secondary'])
|
||||
|
||||
// Icons components
|
||||
const FolderIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /></svg>' }
|
||||
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
|
||||
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
|
||||
const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
|
||||
const SearchIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>' }
|
||||
|
||||
const icons = {
|
||||
folder: FolderIcon,
|
||||
users: UsersIcon,
|
||||
|
||||
@@ -49,8 +49,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
|
||||
import { XIcon } from '@/components/icons'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
|
||||
@@ -29,11 +29,7 @@ const notificationsStore = useNotificationsStore()
|
||||
const { notifications } = storeToRefs(notificationsStore)
|
||||
const { remove, error } = notificationsStore
|
||||
|
||||
// Icon components
|
||||
const CheckIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>' }
|
||||
const ExclamationIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" /></svg>' }
|
||||
const InfoIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>' }
|
||||
const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
|
||||
import { CheckIcon, ExclamationIcon, InfoIcon, XIcon } from '@/components/icons'
|
||||
|
||||
function notificationClasses(type) {
|
||||
const classes = {
|
||||
|
||||
47
frontend/src/components/common/PageHeader.vue
Normal file
47
frontend/src/components/common/PageHeader.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<Breadcrumb v-if="breadcrumbs && breadcrumbs.length" :crumbs="breadcrumbs" />
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<router-link
|
||||
v-if="backTo"
|
||||
:to="backTo"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
|
||||
</router-link>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ title }}</h1>
|
||||
<slot name="meta"></slot>
|
||||
</div>
|
||||
<p v-if="subtitle" class="text-sm text-gray-500 mt-1">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Breadcrumb from '@/components/common/Breadcrumb.vue'
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
backTo: {
|
||||
type: [String, Object],
|
||||
default: null
|
||||
},
|
||||
breadcrumbs: {
|
||||
type: Array,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
62
frontend/src/components/common/TrimesterSelector.vue
Normal file
62
frontend/src/components/common/TrimesterSelector.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="flex gap-1 items-center">
|
||||
<button
|
||||
v-if="showAll"
|
||||
@click="$emit('update:modelValue', null)"
|
||||
class="px-2.5 py-1 rounded text-xs font-medium transition-colors"
|
||||
:class="[
|
||||
sizeClasses,
|
||||
modelValue === null
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
]"
|
||||
>
|
||||
{{ allLabel }}
|
||||
</button>
|
||||
<div v-if="showAll" class="border-l border-gray-300 h-5 mx-1"></div>
|
||||
<button
|
||||
v-for="t in [1, 2, 3]"
|
||||
:key="t"
|
||||
@click="$emit('update:modelValue', t)"
|
||||
class="px-2.5 py-1 rounded text-xs font-medium transition-colors"
|
||||
:class="[
|
||||
sizeClasses,
|
||||
modelValue === t
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
]"
|
||||
>
|
||||
T{{ t }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
showAll: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
allLabel: {
|
||||
type: String,
|
||||
default: 'Tous'
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'sm',
|
||||
validator: (v) => ['sm', 'md'].includes(v)
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const sizeClasses = computed(() => {
|
||||
return props.size === 'md' ? 'px-3 py-1.5 text-sm' : 'px-2.5 py-1 text-xs'
|
||||
})
|
||||
</script>
|
||||
@@ -55,7 +55,7 @@
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded border-2 border-white shadow-sm flex items-center justify-center text-xs font-bold"
|
||||
:style="{ backgroundColor: example.color, color: getTextColor(example.color) }"
|
||||
:style="{ backgroundColor: example.color, color: getTextColorForBg(example.color) }"
|
||||
>
|
||||
{{ example.note }}
|
||||
</div>
|
||||
@@ -257,9 +257,17 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between items-center">
|
||||
<button @click="resetScale" class="btn btn-secondary">
|
||||
<button @click="showResetConfirm = true" class="btn btn-secondary">
|
||||
Valeurs par defaut
|
||||
</button>
|
||||
<ConfirmDialog
|
||||
v-model="showResetConfirm"
|
||||
title="Reinitialiser l'echelle"
|
||||
message="Reinitialiser l'echelle aux valeurs par defaut ?"
|
||||
confirmLabel="Reinitialiser"
|
||||
variant="warning"
|
||||
@confirm="doResetScale"
|
||||
/>
|
||||
|
||||
<button
|
||||
@click="saveScale"
|
||||
@@ -335,6 +343,7 @@ import { useConfigStore } from '@/stores/config'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import ColorPicker from '@/components/common/ColorPicker.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const notifications = useNotificationsStore()
|
||||
@@ -387,86 +396,7 @@ const gradientExamples = computed(() => [
|
||||
{ percent: 0.75, note: 15, color: interpolateColorHSL(gradientForm.value.min_color, gradientForm.value.max_color, 0.75) }
|
||||
])
|
||||
|
||||
// Color functions
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : { r: 0, g: 0, b: 0 }
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
let h, s, l = (max + min) / 2
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
return { h: h * 360, s: s * 100, l: l * 100 }
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
h /= 360; s /= 100; l /= 100
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1/6) return p + (q - p) * 6 * t
|
||||
if (t < 1/2) return q
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
||||
return p
|
||||
}
|
||||
let r, g, b
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1/3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1/3)
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
|
||||
}
|
||||
|
||||
function interpolateColorHSL(color1, color2, factor) {
|
||||
const rgb1 = hexToRgb(color1)
|
||||
const rgb2 = hexToRgb(color2)
|
||||
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b)
|
||||
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b)
|
||||
|
||||
let deltaH = hsl2.h - hsl1.h
|
||||
if (deltaH > 180) hsl2.h -= 360
|
||||
else if (deltaH < -180) hsl2.h += 360
|
||||
|
||||
const h = ((hsl1.h + (hsl2.h - hsl1.h) * factor) % 360 + 360) % 360
|
||||
const s = hsl1.s + (hsl2.s - hsl1.s) * factor
|
||||
const l = hsl1.l + (hsl2.l - hsl1.l) * factor
|
||||
|
||||
const rgb = hslToRgb(h, s, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
function getTextColor(bgColor) {
|
||||
const rgb = hexToRgb(bgColor)
|
||||
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
|
||||
return brightness > 128 ? '#000000' : '#ffffff'
|
||||
}
|
||||
import { interpolateColorHSL, getTextColorForBg } from '@/utils/colors'
|
||||
|
||||
// Actions
|
||||
function updateGradientPreview() {
|
||||
@@ -504,9 +434,9 @@ async function saveScale() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetScale() {
|
||||
if (!confirm('Reinitialiser l\'echelle aux valeurs par defaut ?')) return
|
||||
|
||||
const showResetConfirm = ref(false)
|
||||
|
||||
async function doResetScale() {
|
||||
try {
|
||||
await configStore.resetScale()
|
||||
loadScaleForm()
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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" />
|
||||
|
||||
33
frontend/src/components/icons/index.js
Normal file
33
frontend/src/components/icons/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
export const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
|
||||
|
||||
export const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
|
||||
|
||||
export const CheckIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>' }
|
||||
|
||||
export const ChevronRightIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>' }
|
||||
|
||||
export const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
|
||||
|
||||
export const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
|
||||
|
||||
export const ExclamationIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" /></svg>' }
|
||||
|
||||
export const FolderIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /></svg>' }
|
||||
|
||||
export const HomeIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /></svg>' }
|
||||
|
||||
export const InfoIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" /></svg>' }
|
||||
|
||||
export const MenuIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>' }
|
||||
|
||||
export const PencilIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>' }
|
||||
|
||||
export const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
|
||||
|
||||
export const SearchIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>' }
|
||||
|
||||
export const TrashIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>' }
|
||||
|
||||
export const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
|
||||
|
||||
export const XIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>' }
|
||||
@@ -13,24 +13,12 @@ const routes = [
|
||||
component: () => import('@/views/ClassListView.vue'),
|
||||
meta: { title: 'Classes' }
|
||||
},
|
||||
{
|
||||
path: '/classes/new',
|
||||
name: 'class-create',
|
||||
component: () => import('@/views/ClassFormView.vue'),
|
||||
meta: { title: 'Nouvelle classe' }
|
||||
},
|
||||
{
|
||||
path: '/classes/:id',
|
||||
name: 'class-dashboard',
|
||||
component: () => import('@/views/ClassDashboardView.vue'),
|
||||
meta: { title: 'Dashboard classe' }
|
||||
},
|
||||
{
|
||||
path: '/classes/:id/edit',
|
||||
name: 'class-edit',
|
||||
component: () => import('@/views/ClassFormView.vue'),
|
||||
meta: { title: 'Modifier classe' }
|
||||
},
|
||||
{
|
||||
path: '/classes/:id/students',
|
||||
name: 'class-students',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import assessmentsService from '@/services/assessments'
|
||||
import { withLoading } from './helpers'
|
||||
|
||||
export const useAssessmentsStore = defineStore('assessments', () => {
|
||||
// State
|
||||
@@ -10,7 +11,7 @@ export const useAssessmentsStore = defineStore('assessments', () => {
|
||||
const currentGrades = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
|
||||
// Filters
|
||||
const filters = ref({
|
||||
trimester: null,
|
||||
@@ -21,22 +22,22 @@ export const useAssessmentsStore = defineStore('assessments', () => {
|
||||
|
||||
// Getters
|
||||
const assessmentsCount = computed(() => assessments.value.length)
|
||||
|
||||
|
||||
const filteredAssessments = computed(() => {
|
||||
let result = [...assessments.value]
|
||||
|
||||
|
||||
if (filters.value.trimester) {
|
||||
result = result.filter(a => a.trimester === filters.value.trimester)
|
||||
}
|
||||
|
||||
|
||||
if (filters.value.class_id) {
|
||||
result = result.filter(a => a.class_group_id === filters.value.class_id)
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const incompleteAssessments = computed(() =>
|
||||
const incompleteAssessments = computed(() =>
|
||||
assessments.value.filter(a => {
|
||||
const progress = a.progress || a.grading_progress
|
||||
return progress?.percentage < 100
|
||||
@@ -44,130 +45,61 @@ export const useAssessmentsStore = defineStore('assessments', () => {
|
||||
)
|
||||
|
||||
// Actions
|
||||
async function fetchAssessments(customFilters = null) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const queryFilters = customFilters || filters.value
|
||||
assessments.value = await assessmentsService.getAll(queryFilters)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchAssessments = withLoading(loading, error, async (customFilters = null) => {
|
||||
const queryFilters = customFilters || filters.value
|
||||
assessments.value = await assessmentsService.getAll(queryFilters)
|
||||
})
|
||||
|
||||
async function fetchAssessment(id) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
currentAssessment.value = await assessmentsService.getById(id)
|
||||
return currentAssessment.value
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchAssessment = withLoading(loading, error, async (id) => {
|
||||
currentAssessment.value = await assessmentsService.getById(id)
|
||||
return currentAssessment.value
|
||||
})
|
||||
|
||||
async function fetchResults(id) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
currentResults.value = await assessmentsService.getResults(id)
|
||||
return currentResults.value
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchResults = withLoading(loading, error, async (id) => {
|
||||
currentResults.value = await assessmentsService.getResults(id)
|
||||
return currentResults.value
|
||||
})
|
||||
|
||||
async function fetchGrades(id) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
currentGrades.value = await assessmentsService.getGrades(id)
|
||||
return currentGrades.value
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchGrades = withLoading(loading, error, async (id) => {
|
||||
currentGrades.value = await assessmentsService.getGrades(id)
|
||||
return currentGrades.value
|
||||
})
|
||||
|
||||
async function createAssessment(data) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const newAssessment = await assessmentsService.create(data)
|
||||
assessments.value.unshift(newAssessment)
|
||||
return newAssessment
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const createAssessment = withLoading(loading, error, async (data) => {
|
||||
const newAssessment = await assessmentsService.create(data)
|
||||
assessments.value.unshift(newAssessment)
|
||||
return newAssessment
|
||||
})
|
||||
|
||||
async function updateAssessment(id, data) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const updated = await assessmentsService.update(id, data)
|
||||
const index = assessments.value.findIndex(a => a.id === id)
|
||||
if (index !== -1) {
|
||||
assessments.value[index] = updated
|
||||
}
|
||||
if (currentAssessment.value?.id === id) {
|
||||
currentAssessment.value = updated
|
||||
}
|
||||
return updated
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
const updateAssessment = withLoading(loading, error, async (id, data) => {
|
||||
const updated = await assessmentsService.update(id, data)
|
||||
const index = assessments.value.findIndex(a => a.id === id)
|
||||
if (index !== -1) {
|
||||
assessments.value[index] = updated
|
||||
}
|
||||
}
|
||||
if (currentAssessment.value?.id === id) {
|
||||
currentAssessment.value = updated
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
async function deleteAssessment(id) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await assessmentsService.delete(id)
|
||||
assessments.value = assessments.value.filter(a => a.id !== id)
|
||||
if (currentAssessment.value?.id === id) {
|
||||
currentAssessment.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
const deleteAssessment = withLoading(loading, error, async (id) => {
|
||||
await assessmentsService.delete(id)
|
||||
assessments.value = assessments.value.filter(a => a.id !== id)
|
||||
if (currentAssessment.value?.id === id) {
|
||||
currentAssessment.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function saveGrades(id, grades) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await assessmentsService.saveGrades(id, grades)
|
||||
// Refresh assessment to update progress
|
||||
await fetchAssessment(id)
|
||||
return result
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
// fetchAssessment is declared above so it can be called here safely.
|
||||
// withLoading sets loading=false in its finally block, so the inner call
|
||||
// to fetchAssessment runs as a nested operation within the outer wrapper.
|
||||
const saveGrades = withLoading(loading, error, async (id, grades) => {
|
||||
const result = await assessmentsService.saveGrades(id, grades)
|
||||
// Refresh assessment to update progress
|
||||
await fetchAssessment(id)
|
||||
return result
|
||||
})
|
||||
|
||||
function setFilters(newFilters) {
|
||||
filters.value = { ...filters.value, ...newFilters }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import classesService from '@/services/classes'
|
||||
import { withLoading } from './helpers'
|
||||
|
||||
export const useClassesStore = defineStore('classes', () => {
|
||||
// State
|
||||
@@ -12,104 +13,50 @@ export const useClassesStore = defineStore('classes', () => {
|
||||
|
||||
// Getters
|
||||
const classesCount = computed(() => classes.value.length)
|
||||
const totalStudents = computed(() =>
|
||||
const totalStudents = computed(() =>
|
||||
classes.value.reduce((sum, c) => sum + (c.students_count || c.student_count || 0), 0)
|
||||
)
|
||||
|
||||
// Actions
|
||||
async function fetchClasses() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
classes.value = await classesService.getAll()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchClasses = withLoading(loading, error, async () => {
|
||||
classes.value = await classesService.getAll()
|
||||
})
|
||||
|
||||
async function fetchClass(id) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
currentClass.value = await classesService.getById(id)
|
||||
return currentClass.value
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchClass = withLoading(loading, error, async (id) => {
|
||||
currentClass.value = await classesService.getById(id)
|
||||
return currentClass.value
|
||||
})
|
||||
|
||||
async function fetchClassStats(id, trimester = null) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
currentStats.value = await classesService.getStats(id, trimester)
|
||||
return currentStats.value
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const fetchClassStats = withLoading(loading, error, async (id, trimester = null) => {
|
||||
currentStats.value = await classesService.getStats(id, trimester)
|
||||
return currentStats.value
|
||||
})
|
||||
|
||||
async function createClass(data) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const newClass = await classesService.create(data)
|
||||
classes.value.push(newClass)
|
||||
return newClass
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const createClass = withLoading(loading, error, async (data) => {
|
||||
const newClass = await classesService.create(data)
|
||||
classes.value.push(newClass)
|
||||
return newClass
|
||||
})
|
||||
|
||||
async function updateClass(id, data) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const updated = await classesService.update(id, data)
|
||||
const index = classes.value.findIndex(c => c.id === id)
|
||||
if (index !== -1) {
|
||||
classes.value[index] = updated
|
||||
}
|
||||
if (currentClass.value?.id === id) {
|
||||
currentClass.value = updated
|
||||
}
|
||||
return updated
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
const updateClass = withLoading(loading, error, async (id, data) => {
|
||||
const updated = await classesService.update(id, data)
|
||||
const index = classes.value.findIndex(c => c.id === id)
|
||||
if (index !== -1) {
|
||||
classes.value[index] = updated
|
||||
}
|
||||
}
|
||||
if (currentClass.value?.id === id) {
|
||||
currentClass.value = updated
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
async function deleteClass(id) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await classesService.delete(id)
|
||||
classes.value = classes.value.filter(c => c.id !== id)
|
||||
if (currentClass.value?.id === id) {
|
||||
currentClass.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
const deleteClass = withLoading(loading, error, async (id) => {
|
||||
await classesService.delete(id)
|
||||
classes.value = classes.value.filter(c => c.id !== id)
|
||||
if (currentClass.value?.id === id) {
|
||||
currentClass.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function clearCurrent() {
|
||||
currentClass.value = null
|
||||
|
||||
22
frontend/src/stores/helpers.js
Normal file
22
frontend/src/stores/helpers.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Wraps an async function with loading/error state management.
|
||||
*
|
||||
* @param {import('vue').Ref<boolean>} loading - shared loading ref
|
||||
* @param {import('vue').Ref<string|null>} error - shared error ref
|
||||
* @param {Function} fn - async function to wrap
|
||||
* @returns {Function} wrapped function with identical signature
|
||||
*/
|
||||
export function withLoading(loading, error, fn) {
|
||||
return async (...args) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
return await fn(...args)
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
79
frontend/src/utils/colors.js
Normal file
79
frontend/src/utils/colors.js
Normal file
@@ -0,0 +1,79 @@
|
||||
export function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : { r: 0, g: 0, b: 0 }
|
||||
}
|
||||
|
||||
export function rgbToHex(r, g, b) {
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
|
||||
}
|
||||
|
||||
export function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
let h, s, l = (max + min) / 2
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
return { h: h * 360, s: s * 100, l: l * 100 }
|
||||
}
|
||||
|
||||
export function hslToRgb(h, s, l) {
|
||||
h /= 360; s /= 100; l /= 100
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1/6) return p + (q - p) * 6 * t
|
||||
if (t < 1/2) return q
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
||||
return p
|
||||
}
|
||||
let r, g, b
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1/3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1/3)
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
|
||||
}
|
||||
|
||||
export function interpolateColorHSL(color1, color2, factor) {
|
||||
const rgb1 = hexToRgb(color1)
|
||||
const rgb2 = hexToRgb(color2)
|
||||
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b)
|
||||
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b)
|
||||
|
||||
let deltaH = hsl2.h - hsl1.h
|
||||
if (deltaH > 180) hsl2.h -= 360
|
||||
else if (deltaH < -180) hsl2.h += 360
|
||||
|
||||
const h = ((hsl1.h + (hsl2.h - hsl1.h) * factor) % 360 + 360) % 360
|
||||
const s = hsl1.s + (hsl2.s - hsl1.s) * factor
|
||||
const l = hsl1.l + (hsl2.l - hsl1.l) * factor
|
||||
|
||||
const rgb = hslToRgb(h, s, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
export function getTextColorForBg(bgColor) {
|
||||
const rgb = hexToRgb(bgColor)
|
||||
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
|
||||
return brightness > 128 ? '#000000' : '#ffffff'
|
||||
}
|
||||
@@ -4,53 +4,48 @@
|
||||
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
||||
|
||||
<template v-else-if="assessment">
|
||||
<!-- Hero section -->
|
||||
<div class="bg-gradient-to-r from-primary-600 to-indigo-700 rounded-2xl p-8 mb-8 text-white">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h1 class="text-3xl font-bold">{{ assessment.title }}</h1>
|
||||
<span class="bg-white/20 px-3 py-1 rounded-full text-sm">T{{ assessment.trimester }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-primary-100">
|
||||
<span>{{ assessment.class_name }}</span>
|
||||
<span>{{ formatDate(assessment.date) }}</span>
|
||||
<span>{{ assessment.total_points }} points</span>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
:title="assessment.title"
|
||||
:subtitle="`${assessment.class_name} \u00b7 ${formatDate(assessment.date)} \u00b7 ${assessment.total_points} points`"
|
||||
:breadcrumbs="[{ label: 'Évaluations', to: '/assessments' }, { label: assessment.title }]"
|
||||
>
|
||||
<template #meta>
|
||||
<span class="badge badge-primary">T{{ assessment.trimester }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<ProgressIndicator :progress="assessment.progress" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<router-link
|
||||
<router-link
|
||||
:to="`/assessments/${assessment.id}/grading`"
|
||||
class="card card-body text-center hover:shadow-md transition-shadow"
|
||||
class="card card-body text-center hover:shadow-md transition-shadow bg-primary-50 border-2 border-primary-200"
|
||||
>
|
||||
<PencilIcon class="w-8 h-8 text-primary-600 mx-auto mb-2" />
|
||||
<span class="font-medium">Noter</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
|
||||
<router-link
|
||||
:to="`/assessments/${assessment.id}/results`"
|
||||
class="card card-body text-center hover:shadow-md transition-shadow"
|
||||
>
|
||||
<ChartIcon class="w-8 h-8 text-success-600 mx-auto mb-2" />
|
||||
<span class="font-medium">Résultats</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
|
||||
<router-link
|
||||
:to="`/assessments/${assessment.id}/edit`"
|
||||
class="card card-body text-center hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CogIcon class="w-8 h-8 text-warning-600 mx-auto mb-2" />
|
||||
<span class="font-medium">Modifier</span>
|
||||
</router-link>
|
||||
|
||||
<button
|
||||
|
||||
<button
|
||||
@click="confirmDelete"
|
||||
class="card card-body text-center hover:shadow-md transition-shadow"
|
||||
class="card card-body text-center hover:shadow-md transition-shadow border-dashed border-red-200 opacity-75"
|
||||
>
|
||||
<TrashIcon class="w-8 h-8 text-danger-600 mx-auto mb-2" />
|
||||
<span class="font-medium">Supprimer</span>
|
||||
@@ -225,12 +220,8 @@ import { useNotificationsStore } from '@/stores/notifications'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import ProgressIndicator from '@/components/assessment/ProgressIndicator.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
|
||||
// Icons
|
||||
const PencilIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>' }
|
||||
const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
|
||||
const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
|
||||
const TrashIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>' }
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import { PencilIcon, ChartIcon, CogIcon, TrashIcon } from '@/components/icons'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<!-- Header compact -->
|
||||
<Breadcrumb :crumbs="isEdit
|
||||
? [{ label: 'Évaluations', to: '/assessments' }, { label: form.title || 'Évaluation', to: `/assessments/${route.params.id}` }, { label: 'Modifier' }]
|
||||
: [{ label: 'Évaluations', to: '/assessments' }, { label: 'Nouvelle évaluation' }]
|
||||
" />
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<router-link
|
||||
:to="isEdit ? `/assessments/${route.params.id}` : '/assessments'"
|
||||
class="text-primary-600 hover:text-primary-800 text-sm font-medium"
|
||||
>
|
||||
← Retour
|
||||
</router-link>
|
||||
<h1 class="text-xl font-semibold text-gray-900">
|
||||
{{ isEdit ? 'Modifier l\'évaluation' : 'Nouvelle évaluation' }}
|
||||
</h1>
|
||||
@@ -273,16 +271,26 @@
|
||||
+ Ajouter un exercice
|
||||
</button>
|
||||
</form>
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.show"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:confirmLabel="confirmDialog.confirmLabel"
|
||||
:variant="confirmDialog.variant"
|
||||
@confirm="confirmDialog.onConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
|
||||
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||
import { useAssessmentsStore } from '@/stores/assessments'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import DomainAutocomplete from '@/components/assessment/DomainAutocomplete.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Breadcrumb from '@/components/common/Breadcrumb.vue'
|
||||
import configService from '@/services/config'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -295,6 +303,20 @@ const isEdit = computed(() => !!route.params.id)
|
||||
const submitting = ref(false)
|
||||
const classes = computed(() => classesStore.classes)
|
||||
const competences = ref([])
|
||||
const formDirty = ref(false)
|
||||
|
||||
const confirmDialog = ref({
|
||||
show: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmLabel: 'Confirmer',
|
||||
variant: 'danger',
|
||||
onConfirm: () => {}
|
||||
})
|
||||
|
||||
function showConfirm(opts) {
|
||||
confirmDialog.value = { show: true, ...opts }
|
||||
}
|
||||
|
||||
// Computed pour le récapitulatif
|
||||
const totalElements = computed(() => {
|
||||
@@ -368,6 +390,29 @@ const form = ref({
|
||||
exercises: []
|
||||
})
|
||||
|
||||
watch(form, () => {
|
||||
formDirty.value = true
|
||||
}, { deep: true })
|
||||
|
||||
onBeforeRouteLeave((to, from, next) => {
|
||||
if (formDirty.value && !submitting.value) {
|
||||
if (confirm('Vous avez des modifications non sauvegardées. Quitter cette page ?')) {
|
||||
next()
|
||||
} else {
|
||||
next(false)
|
||||
}
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
function handleBeforeUnload(event) {
|
||||
if (formDirty.value) {
|
||||
event.preventDefault()
|
||||
event.returnValue = 'Vous avez des modifications non sauvegardées.'
|
||||
}
|
||||
}
|
||||
|
||||
function addExercise() {
|
||||
const newOrder = form.value.exercises.length + 1
|
||||
form.value.exercises.push({
|
||||
@@ -387,8 +432,22 @@ function addExercise() {
|
||||
}
|
||||
|
||||
function removeExercise(idx) {
|
||||
const exercise = form.value.exercises[idx]
|
||||
if (isEdit.value && exercise.id) {
|
||||
showConfirm({
|
||||
title: 'Supprimer l\'exercice',
|
||||
message: 'Cet exercice contient potentiellement des notes. Supprimer ?',
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger',
|
||||
onConfirm: () => doRemoveExercise(idx)
|
||||
})
|
||||
return
|
||||
}
|
||||
doRemoveExercise(idx)
|
||||
}
|
||||
|
||||
function doRemoveExercise(idx) {
|
||||
form.value.exercises.splice(idx, 1)
|
||||
// Renumber exercises
|
||||
form.value.exercises.forEach((ex, i) => {
|
||||
ex.order = i + 1
|
||||
})
|
||||
@@ -417,6 +476,17 @@ function addElement(exIdx) {
|
||||
}
|
||||
|
||||
function removeElement(exIdx, elIdx) {
|
||||
const element = form.value.exercises[exIdx].grading_elements[elIdx]
|
||||
if (isEdit.value && element.id) {
|
||||
showConfirm({
|
||||
title: 'Supprimer l\'élément',
|
||||
message: 'Cet élément contient potentiellement des notes. Supprimer ?',
|
||||
confirmLabel: 'Supprimer',
|
||||
variant: 'danger',
|
||||
onConfirm: () => form.value.exercises[exIdx].grading_elements.splice(elIdx, 1)
|
||||
})
|
||||
return
|
||||
}
|
||||
form.value.exercises[exIdx].grading_elements.splice(elIdx, 1)
|
||||
}
|
||||
|
||||
@@ -429,10 +499,20 @@ async function submit() {
|
||||
|
||||
const hasEmptyExercises = form.value.exercises.some(ex => ex.grading_elements.length === 0)
|
||||
if (hasEmptyExercises) {
|
||||
if (!confirm('Certains exercices n\'ont pas d\'éléments de notation. Voulez-vous continuer ?')) {
|
||||
return
|
||||
}
|
||||
showConfirm({
|
||||
title: 'Exercices vides',
|
||||
message: 'Certains exercices n\'ont pas d\'éléments de notation. Voulez-vous continuer ?',
|
||||
confirmLabel: 'Continuer',
|
||||
variant: 'warning',
|
||||
onConfirm: () => doSubmit()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
doSubmit()
|
||||
}
|
||||
|
||||
async function doSubmit() {
|
||||
|
||||
// Prepare data for API
|
||||
const data = {
|
||||
@@ -463,22 +543,20 @@ async function submit() {
|
||||
data.class_group_id = form.value.class_group_id
|
||||
}
|
||||
|
||||
console.log('Sending data:', JSON.stringify(data, null, 2))
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await assessmentsStore.updateAssessment(route.params.id, data)
|
||||
notifications.success('Évaluation modifiée avec succès')
|
||||
formDirty.value = false
|
||||
router.push(`/assessments/${route.params.id}`)
|
||||
} else {
|
||||
const created = await assessmentsStore.createAssessment(data)
|
||||
notifications.success('Évaluation créée avec succès')
|
||||
formDirty.value = false
|
||||
router.push(`/assessments/${created.id}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error saving assessment:', e)
|
||||
console.error('Response data:', JSON.stringify(e.response?.data, null, 2))
|
||||
const detail = e.response?.data?.detail
|
||||
const errorMsg = Array.isArray(detail)
|
||||
? detail.map(d => `${d.loc?.join('.')}: ${d.msg}`).join(', ')
|
||||
@@ -526,6 +604,13 @@ onMounted(async () => {
|
||||
// Add first exercise for new assessment
|
||||
addExercise()
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
// Reset dirty flag after initial load
|
||||
nextTick(() => { formDirty.value = false })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
async function loadCompetences() {
|
||||
|
||||
@@ -1,84 +1,43 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Hero section amélioré -->
|
||||
<div class="bg-gradient-to-r from-warning-500 to-orange-600 rounded-2xl p-8 mb-8 text-white">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">Évaluations</h1>
|
||||
<p class="text-warning-100 mb-4">{{ assessments.length }} évaluation(s)</p>
|
||||
|
||||
<!-- Stats rapides -->
|
||||
<div class="flex flex-wrap gap-4 text-sm text-warning-100">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ completedCount }} terminées</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ inProgressCount }} en cours</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>{{ notStartedCount }} non commencées</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<router-link to="/assessments/new" class="btn bg-white text-warning-600 hover:bg-warning-50">
|
||||
<PageHeader title="Évaluations">
|
||||
<template #meta>
|
||||
<span class="badge badge-success">{{ completedCount }} terminées</span>
|
||||
<span class="badge badge-warning">{{ inProgressCount }} en cours</span>
|
||||
<span class="badge badge-danger">{{ notStartedCount }} non commencées</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<router-link to="/assessments/new" class="btn btn-primary">
|
||||
<PlusIcon class="w-5 h-5 mr-2 inline" />
|
||||
Nouvelle évaluation
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card card-body mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label class="label">Trimestre</label>
|
||||
<select v-model="filters.trimester" class="input" @change="applyFilters">
|
||||
<option :value="null">Tous</option>
|
||||
<option :value="1">Trimestre 1</option>
|
||||
<option :value="2">Trimestre 2</option>
|
||||
<option :value="3">Trimestre 3</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Classe</label>
|
||||
<select v-model="filters.class_id" class="input" @change="applyFilters">
|
||||
<option :value="null">Toutes</option>
|
||||
<option v-for="cls in classes" :key="cls.id" :value="cls.id">
|
||||
{{ cls.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Statut</label>
|
||||
<select v-model="filters.status" class="input" @change="applyFilters">
|
||||
<option value="all">Tous</option>
|
||||
<option value="incomplete">Non terminées</option>
|
||||
<option value="complete">Terminées</option>
|
||||
<option value="not_started">Non commencées</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Tri</label>
|
||||
<select v-model="filters.sort" class="input" @change="applyFilters">
|
||||
<option value="date_desc">Date (récent)</option>
|
||||
<option value="date_asc">Date (ancien)</option>
|
||||
<option value="title">Titre (A-Z)</option>
|
||||
<option value="class">Classe</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Inline filter toolbar -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||
<TrimesterSelector v-model="filters.trimester" showAll allLabel="Tous" size="sm" />
|
||||
<select v-model="filters.class_id" class="input text-sm py-1.5 w-auto" @change="applyFilters">
|
||||
<option :value="null">Toutes les classes</option>
|
||||
<option v-for="cls in classes" :key="cls.id" :value="cls.id">
|
||||
{{ cls.name }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="filters.status" class="input text-sm py-1.5 w-auto" @change="applyFilters">
|
||||
<option value="all">Tous statuts</option>
|
||||
<option value="incomplete">Non terminées</option>
|
||||
<option value="complete">Terminées</option>
|
||||
<option value="not_started">Non commencées</option>
|
||||
</select>
|
||||
<select v-model="filters.sort" class="input text-sm py-1.5 w-auto" @change="applyFilters">
|
||||
<option value="date_desc">Date (récent)</option>
|
||||
<option value="date_asc">Date (ancien)</option>
|
||||
<option value="title">Titre (A-Z)</option>
|
||||
<option value="class">Classe</option>
|
||||
</select>
|
||||
<button v-if="hasActiveFilters" @click="resetFilters" class="text-xs text-gray-500 hover:text-gray-700 underline">
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
@@ -99,56 +58,61 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Assessments list -->
|
||||
<div v-else class="space-y-4">
|
||||
<router-link
|
||||
v-for="assessment in filteredAssessments"
|
||||
:key="assessment.id"
|
||||
:to="`/assessments/${assessment.id}`"
|
||||
class="card card-body hover:shadow-md transition-shadow block"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold">{{ assessment.title }}</h3>
|
||||
<span class="badge badge-primary">T{{ assessment.trimester }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>{{ assessment.class_name }}</span>
|
||||
<span>{{ formatDate(assessment.date) }}</span>
|
||||
<span>{{ assessment.total_points }} pts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<ProgressIndicator
|
||||
:progress="assessment.progress"
|
||||
size="md"
|
||||
:clickable="true"
|
||||
@click.prevent="goToGrading(assessment.id)"
|
||||
/>
|
||||
<ChevronRightIcon class="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
<!-- Compact table -->
|
||||
<div v-else class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 border-b border-gray-200">
|
||||
<th class="px-4 py-2.5 text-left font-medium text-gray-600">Titre</th>
|
||||
<th class="px-4 py-2.5 text-left font-medium text-gray-600">Classe</th>
|
||||
<th class="px-4 py-2.5 text-left font-medium text-gray-600">Date</th>
|
||||
<th class="px-4 py-2.5 text-center font-medium text-gray-600">Points</th>
|
||||
<th class="px-4 py-2.5 text-center font-medium text-gray-600">Trimestre</th>
|
||||
<th class="px-4 py-2.5 text-center font-medium text-gray-600">Progression</th>
|
||||
<th class="px-4 py-2.5 text-right font-medium text-gray-600"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="assessment in filteredAssessments"
|
||||
:key="assessment.id"
|
||||
class="border-b border-gray-100 hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
@click="$router.push(`/assessments/${assessment.id}`)"
|
||||
>
|
||||
<td class="px-4 py-2.5 font-medium text-gray-900">{{ assessment.title }}</td>
|
||||
<td class="px-4 py-2.5 text-gray-600">{{ assessment.class_name }}</td>
|
||||
<td class="px-4 py-2.5 text-gray-600">{{ formatDate(assessment.date) }}</td>
|
||||
<td class="px-4 py-2.5 text-center text-gray-600">{{ assessment.total_points }}</td>
|
||||
<td class="px-4 py-2.5 text-center"><span class="badge badge-primary">T{{ assessment.trimester }}</span></td>
|
||||
<td class="px-4 py-2.5 text-center">
|
||||
<ProgressIndicator :progress="assessment.progress" size="sm" />
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<router-link
|
||||
:to="`/assessments/${assessment.id}/grading`"
|
||||
class="text-xs font-medium text-primary-600 hover:text-primary-800"
|
||||
@click.stop
|
||||
>
|
||||
Corriger
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAssessmentsStore } from '@/stores/assessments'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import ProgressIndicator from '@/components/assessment/ProgressIndicator.vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import TrimesterSelector from '@/components/common/TrimesterSelector.vue'
|
||||
import { PlusIcon, ClipboardIcon } from '@/components/icons'
|
||||
|
||||
// Icons
|
||||
const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
|
||||
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
|
||||
const ChevronRightIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>' }
|
||||
|
||||
const router = useRouter()
|
||||
const assessmentsStore = useAssessmentsStore()
|
||||
const classesStore = useClassesStore()
|
||||
|
||||
@@ -218,6 +182,10 @@ function formatDate(dateStr) {
|
||||
})
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
filters.value.trimester !== null || filters.value.class_id !== null || filters.value.status !== 'all'
|
||||
)
|
||||
|
||||
function applyFilters() {
|
||||
// Filters are reactive, computed will update automatically
|
||||
}
|
||||
@@ -231,10 +199,6 @@ function resetFilters() {
|
||||
}
|
||||
}
|
||||
|
||||
function goToGrading(id) {
|
||||
router.push(`/assessments/${id}/grading`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
|
||||
@@ -1,63 +1,34 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="px-4 sm:px-6 lg:px-8 py-8">
|
||||
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
||||
|
||||
<template v-else-if="classData">
|
||||
<!-- Hero amélioré -->
|
||||
<div class="bg-gradient-to-br from-blue-50 to-indigo-100 rounded-xl p-6 md:p-8 mb-8">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">{{ classData.name }}</h1>
|
||||
<p class="text-gray-600">{{ classData.year }} - {{ classData.students_count }} élèves</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<router-link :to="`/classes/${classData.id}/students`" class="btn btn-secondary">
|
||||
Élèves
|
||||
</router-link>
|
||||
<router-link :to="`/classes/${classData.id}/council`" class="btn btn-secondary">
|
||||
Conseil
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
:title="classData.name"
|
||||
:subtitle="`${classData.year} \u00b7 ${classData.students_count} élèves`"
|
||||
:breadcrumbs="[{ label: 'Classes', to: '/classes' }, { label: classData.name }]"
|
||||
>
|
||||
<template #actions>
|
||||
<router-link :to="`/classes/${classData.id}/students`" class="btn btn-secondary">
|
||||
Élèves
|
||||
</router-link>
|
||||
<router-link :to="`/classes/${classData.id}/council`" class="btn btn-secondary">
|
||||
Conseil
|
||||
</router-link>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Trimester selector -->
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<!-- Vision annuelle -->
|
||||
<button
|
||||
@click="selectTrimester(null)"
|
||||
class="btn"
|
||||
:class="trimester === null ? 'btn-primary' : 'btn-secondary'"
|
||||
>
|
||||
📊 Vision annuelle
|
||||
</button>
|
||||
|
||||
<!-- Séparateur visuel -->
|
||||
<div class="border-l border-gray-300 h-8 mx-1"></div>
|
||||
|
||||
<!-- Trimestres individuels -->
|
||||
<button
|
||||
v-for="t in [1, 2, 3]"
|
||||
:key="t"
|
||||
@click="selectTrimester(t)"
|
||||
class="btn"
|
||||
:class="trimester === t ? 'btn-primary' : 'btn-secondary'"
|
||||
>
|
||||
Trimestre {{ t }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur de période affichée -->
|
||||
<div class="mt-3 text-center">
|
||||
<p class="text-sm font-medium text-gray-600">
|
||||
{{ trimester === null ? '📊 Toutes les évaluations de l\'année' : `📅 Évaluations du trimestre ${trimester}` }}
|
||||
</p>
|
||||
</div>
|
||||
<TrimesterSelector v-model="trimester" showAll allLabel="Annuel" size="md" @update:modelValue="selectTrimester" />
|
||||
</div>
|
||||
|
||||
<!-- Stats principales - Grid 4 colonnes -->
|
||||
<div v-if="stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div v-if="stats" class="relative">
|
||||
<div v-if="statsLoading" class="absolute inset-0 bg-white/60 z-10 flex items-center justify-center rounded-xl">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<!-- Moyenne classe -->
|
||||
<div class="bg-white rounded-xl shadow-md p-6 hover:shadow-lg transition-all duration-300">
|
||||
<p class="text-sm text-gray-500 mb-1">Moyenne classe</p>
|
||||
@@ -96,6 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Domaines et Compétences en 2 colonnes -->
|
||||
<div v-if="stats" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
@@ -233,6 +205,8 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import TrimesterSelector from '@/components/common/TrimesterSelector.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const classesStore = useClassesStore()
|
||||
@@ -243,6 +217,7 @@ const stats = ref(null)
|
||||
const trimester = ref(null) // null = vision annuelle par défaut
|
||||
const sortColumn = ref('name')
|
||||
const sortDirection = ref('asc')
|
||||
const statsLoading = ref(false)
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
@@ -257,7 +232,12 @@ async function fetchData() {
|
||||
|
||||
async function selectTrimester(t) {
|
||||
trimester.value = t
|
||||
stats.value = await classesStore.fetchClassStats(route.params.id, t)
|
||||
statsLoading.value = true
|
||||
try {
|
||||
stats.value = await classesStore.fetchClassStats(route.params.id, t)
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer la liste des évaluations triée par date
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ isEdit ? 'Modifier' : 'Nouvelle' }} classe</h1>
|
||||
|
||||
<form @submit.prevent="submit" class="card card-body space-y-4">
|
||||
<div>
|
||||
<label class="label">Nom de la classe *</label>
|
||||
<input v-model="form.name" type="text" class="input" required placeholder="Ex: 6ème A" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Année scolaire *</label>
|
||||
<input v-model="form.year" type="text" class="input" required placeholder="Ex: 2024-2025" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<textarea v-model="form.description" class="input" rows="3" placeholder="Description optionnelle"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<router-link to="/classes" class="btn btn-secondary">Annuler</router-link>
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? 'Enregistrement...' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const classesStore = useClassesStore()
|
||||
const notifications = useNotificationsStore()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
year: new Date().getFullYear() + '-' + (new Date().getFullYear() + 1),
|
||||
description: ''
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await classesStore.updateClass(route.params.id, form.value)
|
||||
notifications.success('Classe modifiée')
|
||||
} else {
|
||||
await classesStore.createClass(form.value)
|
||||
notifications.success('Classe créée')
|
||||
}
|
||||
router.push('/classes')
|
||||
} catch (e) {
|
||||
notifications.error('Erreur lors de l\'enregistrement')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
const cls = await classesStore.fetchClass(route.params.id)
|
||||
form.value = {
|
||||
name: cls.name,
|
||||
year: cls.year,
|
||||
description: cls.description || ''
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,18 +1,16 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Hero section -->
|
||||
<div class="bg-gradient-to-r from-primary-600 to-primary-800 rounded-2xl p-8 mb-8 text-white">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">Classes</h1>
|
||||
<p class="text-primary-100">{{ classes.length }} classe(s) - {{ totalStudents }} élève(s)</p>
|
||||
</div>
|
||||
<router-link to="/classes/new" class="btn bg-white text-primary-600 hover:bg-primary-50">
|
||||
<PageHeader title="Classes">
|
||||
<template #meta>
|
||||
<span class="badge badge-primary">{{ classes.length }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<button @click="openCreateModal" class="btn btn-primary">
|
||||
<PlusIcon class="w-5 h-5 mr-2 inline" />
|
||||
Nouvelle classe
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Loading state avec skeleton -->
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -25,8 +23,11 @@
|
||||
title="Aucune classe"
|
||||
description="Créez votre première classe pour commencer à gérer vos élèves et évaluations."
|
||||
icon="users"
|
||||
:primaryAction="{ to: '/classes/new', label: 'Créer une classe' }"
|
||||
/>
|
||||
>
|
||||
<template #actions>
|
||||
<button @click="openCreateModal" class="btn btn-primary">Créer une classe</button>
|
||||
</template>
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
||||
<!-- Classes grid v2.0 -->
|
||||
@@ -50,7 +51,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
|
||||
<!-- Contenu avec actions contextuelles -->
|
||||
<div class="p-4">
|
||||
<!-- Description -->
|
||||
@@ -60,17 +61,17 @@
|
||||
<p v-else class="text-sm text-gray-400 mb-4 italic">
|
||||
Aucune description
|
||||
</p>
|
||||
|
||||
|
||||
<!-- Actions principales avec quantités -->
|
||||
<div class="grid grid-cols-2 gap-2 mb-3">
|
||||
<router-link
|
||||
<router-link
|
||||
:to="`/classes/${cls.id}/students`"
|
||||
:class="`${getAccentBgClass(cls.name)} px-3 py-2.5 rounded-lg text-xs font-medium transition-colors flex items-center justify-center space-x-1`"
|
||||
>
|
||||
<UsersIcon class="w-4 h-4" />
|
||||
<span>{{ cls.students_count || 0 }} Élève{{ (cls.students_count || 0) !== 1 ? 's' : '' }}</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
<router-link
|
||||
:to="`/assessments?class_id=${cls.id}`"
|
||||
class="bg-gray-50 hover:bg-gray-100 text-gray-700 hover:text-gray-900 px-3 py-2.5 rounded-lg text-xs font-medium transition-colors flex items-center justify-center space-x-1"
|
||||
>
|
||||
@@ -78,16 +79,16 @@
|
||||
<span>{{ cls.assessments_count || 0 }} Éval{{ (cls.assessments_count || 0) !== 1 ? 's' : '' }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Actions secondaires -->
|
||||
<div class="pt-3 border-t border-gray-100 flex gap-2">
|
||||
<router-link
|
||||
:to="`/classes/${cls.id}/edit`"
|
||||
<button
|
||||
@click="openEditModal(cls)"
|
||||
class="flex-1 bg-gray-50 hover:bg-gray-100 text-gray-600 hover:text-gray-800 px-3 py-2 rounded-lg text-xs font-medium transition-colors text-center"
|
||||
>
|
||||
Modifier
|
||||
</router-link>
|
||||
<button
|
||||
</button>
|
||||
<button
|
||||
@click.stop="confirmDelete(cls)"
|
||||
class="flex-1 bg-red-50 hover:bg-red-100 text-red-600 hover:text-red-800 px-3 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
>
|
||||
@@ -97,30 +98,125 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Class form modal (create/edit) -->
|
||||
<Modal v-model="showFormModal" :title="editingClass ? 'Modifier la classe' : 'Nouvelle classe'" size="sm">
|
||||
<form @submit.prevent="submitForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Nom de la classe *</label>
|
||||
<input v-model="form.name" type="text" class="input" required placeholder="Ex: 6ème A" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Année scolaire *</label>
|
||||
<input v-model="form.year" type="text" class="input" required placeholder="Ex: 2024-2025" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<textarea v-model="form.description" class="input" rows="3" placeholder="Description optionnelle"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showFormModal = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button @click="submitForm" class="btn btn-primary" :disabled="submitting || !form.name || !form.year">
|
||||
{{ submitting ? 'Enregistrement...' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<Modal v-model="showDeleteModal" title="Confirmer la suppression" size="sm">
|
||||
<p class="text-gray-600">
|
||||
Êtes-vous sûr de vouloir supprimer la classe
|
||||
<strong>{{ classToDelete?.name }}</strong> ?
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Cette classe contient <strong>{{ classToDelete?.students_count || 0 }}</strong> élève(s).
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showDeleteModal = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button @click="executeDelete" class="btn btn-danger">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import SkeletonLoader from '@/components/common/SkeletonLoader.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
|
||||
// Icons
|
||||
const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
|
||||
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
|
||||
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import { PlusIcon, UsersIcon, ClipboardIcon } from '@/components/icons'
|
||||
|
||||
const classesStore = useClassesStore()
|
||||
const notifications = useNotificationsStore()
|
||||
const loading = ref(true)
|
||||
|
||||
// Delete modal
|
||||
const showDeleteModal = ref(false)
|
||||
const classToDelete = ref(null)
|
||||
|
||||
// Form modal
|
||||
const showFormModal = ref(false)
|
||||
const editingClass = ref(null)
|
||||
const submitting = ref(false)
|
||||
const form = ref({ name: '', year: '', description: '' })
|
||||
|
||||
const classes = computed(() => classesStore.classes)
|
||||
const totalStudents = computed(() => classesStore.totalStudents)
|
||||
|
||||
function defaultYear() {
|
||||
const y = new Date().getFullYear()
|
||||
return `${y}-${y + 1}`
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingClass.value = null
|
||||
form.value = { name: '', year: defaultYear(), description: '' }
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(cls) {
|
||||
editingClass.value = cls
|
||||
form.value = { name: cls.name, year: cls.year, description: cls.description || '' }
|
||||
showFormModal.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!form.value.name || !form.value.year) return
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editingClass.value) {
|
||||
await classesStore.updateClass(editingClass.value.id, form.value)
|
||||
notifications.success('Classe modifiée')
|
||||
} else {
|
||||
await classesStore.createClass(form.value)
|
||||
notifications.success('Classe créée')
|
||||
}
|
||||
showFormModal.value = false
|
||||
} catch (e) {
|
||||
notifications.error('Erreur lors de l\'enregistrement')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour obtenir le gradient selon le niveau scolaire
|
||||
function getGradientClass(className) {
|
||||
if (!className) return 'from-gray-500 to-gray-600'
|
||||
|
||||
|
||||
const firstChar = className[0].toUpperCase()
|
||||
const gradients = {
|
||||
'6': 'from-blue-500 to-blue-600',
|
||||
@@ -137,7 +233,7 @@ function getGradientClass(className) {
|
||||
// Fonction pour obtenir les classes d'accent pour les boutons
|
||||
function getAccentBgClass(className) {
|
||||
if (!className) return 'bg-gray-50 hover:bg-gray-100 text-gray-700 hover:text-gray-900'
|
||||
|
||||
|
||||
const firstChar = className[0].toUpperCase()
|
||||
const accents = {
|
||||
'6': 'bg-blue-50 hover:bg-blue-100 text-blue-700 hover:text-blue-900',
|
||||
@@ -153,9 +249,16 @@ function getAccentBgClass(className) {
|
||||
|
||||
// Confirmation de suppression
|
||||
function confirmDelete(cls) {
|
||||
if (confirm(`Êtes-vous sûr de vouloir supprimer la classe "${cls.name}" ?`)) {
|
||||
classesStore.deleteClass(cls.id)
|
||||
classToDelete.value = cls
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
function executeDelete() {
|
||||
if (classToDelete.value) {
|
||||
classesStore.deleteClass(classToDelete.value.id)
|
||||
}
|
||||
showDeleteModal.value = false
|
||||
classToDelete.value = null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<Breadcrumb :crumbs="[
|
||||
{ label: 'Classes', to: '/classes' },
|
||||
{ label: classData?.name, to: `/classes/${classData?.id}` },
|
||||
{ label: 'Élèves' }
|
||||
]" />
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ classData?.name }}</h1>
|
||||
@@ -15,7 +20,7 @@
|
||||
type="checkbox"
|
||||
v-model="includeDeparted"
|
||||
@change="loadStudents"
|
||||
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700">Inclure les élèves partis</span>
|
||||
</label>
|
||||
@@ -58,7 +63,7 @@
|
||||
@keyup.enter="saveEmail(student)"
|
||||
@keyup.escape="cancelEditEmail"
|
||||
type="email"
|
||||
class="flex-1 px-2 py-1 border border-indigo-300 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="flex-1 px-2 py-1 border border-primary-300 rounded focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="email@exemple.com"
|
||||
/>
|
||||
<button @click="cancelEditEmail" class="text-gray-400 hover:text-gray-600">
|
||||
@@ -71,7 +76,7 @@
|
||||
<span class="flex-1 text-gray-600">{{ student.email || '-' }}</span>
|
||||
<button
|
||||
@click="startEditEmail(student)"
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-indigo-600 transition-opacity"
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-primary-600 transition-opacity"
|
||||
title="Modifier l'email"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -127,7 +132,7 @@
|
||||
<button
|
||||
v-else
|
||||
@click="openReenrollModal(student)"
|
||||
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
class="text-sm text-primary-600 hover:text-primary-800 font-medium"
|
||||
>
|
||||
Réinscrire
|
||||
</button>
|
||||
@@ -154,7 +159,7 @@
|
||||
:class="[
|
||||
'py-4 px-1 border-b-2 font-medium text-sm',
|
||||
addMode === 'new'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
]"
|
||||
>
|
||||
@@ -165,7 +170,7 @@
|
||||
:class="[
|
||||
'py-4 px-1 border-b-2 font-medium text-sm',
|
||||
addMode === 'existing'
|
||||
? 'border-indigo-500 text-indigo-600'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
]"
|
||||
>
|
||||
@@ -182,7 +187,7 @@
|
||||
v-model="newStudent.last_name"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -191,7 +196,7 @@
|
||||
v-model="newStudent.first_name"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -199,7 +204,7 @@
|
||||
<input
|
||||
v-model="newStudent.email"
|
||||
type="email"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -208,7 +213,7 @@
|
||||
v-model="newStudent.enrollment_date"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -216,7 +221,7 @@
|
||||
<input
|
||||
v-model="newStudent.enrollment_reason"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Ex: Nouvelle inscription"
|
||||
/>
|
||||
</div>
|
||||
@@ -228,7 +233,7 @@
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Élève *</label>
|
||||
<select
|
||||
v-model="existingStudent.student_id"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null">Sélectionner un élève...</option>
|
||||
<option v-for="student in availableStudents" :key="student.id" :value="student.id">
|
||||
@@ -242,7 +247,7 @@
|
||||
v-model="existingStudent.enrollment_date"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -250,7 +255,7 @@
|
||||
<input
|
||||
v-model="existingStudent.enrollment_reason"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Ex: Transfert depuis..."
|
||||
/>
|
||||
</div>
|
||||
@@ -258,17 +263,10 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="showAddModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<button @click="showAddModal = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="enrollStudent"
|
||||
:disabled="!canEnroll"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<button @click="enrollStudent" :disabled="!canEnroll" class="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Inscrire
|
||||
</button>
|
||||
</div>
|
||||
@@ -287,7 +285,7 @@
|
||||
v-model="departureDate"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -295,23 +293,16 @@
|
||||
<input
|
||||
v-model="departureReason"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Ex: Déménagement, transfert..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="showDepartureModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<button @click="showDepartureModal = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDeparture"
|
||||
:disabled="!departureDate"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<button @click="confirmDeparture" :disabled="!departureDate" class="btn btn-danger disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Confirmer le départ
|
||||
</button>
|
||||
</div>
|
||||
@@ -330,7 +321,7 @@
|
||||
v-model="reenrollDate"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -338,23 +329,16 @@
|
||||
<input
|
||||
v-model="reenrollReason"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Ex: Retour après absence..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
@click="showReenrollModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<button @click="showReenrollModal = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="confirmReenroll"
|
||||
:disabled="!reenrollDate"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<button @click="confirmReenroll" :disabled="!reenrollDate" class="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Réinscrire
|
||||
</button>
|
||||
</div>
|
||||
@@ -372,6 +356,7 @@ import classesService from '@/services/classes'
|
||||
import studentsService from '@/services/students'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import Breadcrumb from '@/components/common/Breadcrumb.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const classesStore = useClassesStore()
|
||||
@@ -440,7 +425,6 @@ async function loadStudents() {
|
||||
students.value = await classesService.getStudents(id, null, includeDeparted.value)
|
||||
} catch (error) {
|
||||
notifications.error('Erreur lors du chargement des élèves')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,7 +435,6 @@ async function loadAvailableStudents() {
|
||||
availableStudents.value = allStudents.filter(s => !s.current_class_id)
|
||||
} catch (error) {
|
||||
notifications.error('Erreur lors du chargement des élèves disponibles')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +477,6 @@ async function saveEmail(student) {
|
||||
}, 2000)
|
||||
|
||||
notifications.error(error.response?.data?.detail || 'Erreur lors de la mise à jour de l\'email')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +523,6 @@ async function enrollStudent() {
|
||||
await loadStudents()
|
||||
} catch (error) {
|
||||
notifications.error(error.response?.data?.detail || 'Erreur lors de l\'inscription')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,7 +547,6 @@ async function confirmDeparture() {
|
||||
await loadStudents()
|
||||
} catch (error) {
|
||||
notifications.error(error.response?.data?.detail || 'Erreur lors de l\'enregistrement du départ')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,7 +574,6 @@ async function confirmReenroll() {
|
||||
await loadStudents()
|
||||
} catch (error) {
|
||||
notifications.error(error.response?.data?.detail || 'Erreur lors de la réinscription')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Hero Section -->
|
||||
<div class="bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-xl p-8 shadow-lg mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">Configuration</h1>
|
||||
<p class="text-lg opacity-90">Personnalisez votre application Notytex</p>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
<div class="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center">
|
||||
<svg class="w-10 h-10" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="Configuration" />
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="configStore.loading" class="flex justify-center py-12">
|
||||
@@ -54,6 +39,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import ConfigGeneralTab from '@/components/config/ConfigGeneralTab.vue'
|
||||
import ConfigCompetencesTab from '@/components/config/ConfigCompetencesTab.vue'
|
||||
import ConfigDomainsTab from '@/components/config/ConfigDomainsTab.vue'
|
||||
@@ -63,10 +49,10 @@ import ConfigEmailTab from '@/components/config/ConfigEmailTab.vue'
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', label: 'General' },
|
||||
{ id: 'competences', label: 'Competences' },
|
||||
{ id: 'general', label: 'Général' },
|
||||
{ id: 'competences', label: 'Compétences' },
|
||||
{ id: 'domains', label: 'Domaines' },
|
||||
{ id: 'scale', label: 'Echelle' },
|
||||
{ id: 'scale', label: 'Échelle' },
|
||||
{ id: 'email', label: 'Email' }
|
||||
]
|
||||
|
||||
|
||||
@@ -1,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()
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Hero section -->
|
||||
<div class="bg-gradient-to-r from-primary-600 to-primary-800 rounded-2xl p-8 mb-8 text-white">
|
||||
<h1 class="text-3xl font-bold mb-2">Tableau de bord</h1>
|
||||
<p class="text-primary-100">Bienvenue sur Notytex - Gestion des évaluations scolaires</p>
|
||||
</div>
|
||||
<PageHeader title="Tableau de bord" />
|
||||
|
||||
<!-- Loading state -->
|
||||
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
||||
@@ -57,9 +53,12 @@
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<div class="card card-body">
|
||||
<router-link
|
||||
to="/assessments"
|
||||
class="card card-body hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 rounded-lg bg-danger-100 text-danger-600">
|
||||
<div class="p-3 rounded-lg bg-danger-100 text-danger-600 group-hover:bg-danger-200 transition-colors">
|
||||
<PencilIcon class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
@@ -67,7 +66,7 @@
|
||||
<p class="text-2xl font-bold">{{ assessmentsStore.incompleteAssessments.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions -->
|
||||
@@ -149,16 +148,9 @@ import { onMounted, ref } from 'vue'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import { useAssessmentsStore } from '@/stores/assessments'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import ProgressIndicator from '@/components/assessment/ProgressIndicator.vue'
|
||||
|
||||
// Icons
|
||||
const UsersIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" /></svg>' }
|
||||
const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
|
||||
const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25ZM6.75 12h.008v.008H6.75V12Zm0 3h.008v.008H6.75V15Zm0 3h.008v.008H6.75V18Z" /></svg>' }
|
||||
const PencilIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>' }
|
||||
const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
|
||||
const ChartIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z" /></svg>' }
|
||||
const CogIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>' }
|
||||
import { UsersIcon, AcademicCapIcon, ClipboardIcon, PencilIcon, PlusIcon, ChartIcon, CogIcon } from '@/components/icons'
|
||||
|
||||
const classesStore = useClassesStore()
|
||||
const assessmentsStore = useAssessmentsStore()
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
<template>
|
||||
<div class="h-screen w-screen overflow-hidden flex flex-col bg-gray-50">
|
||||
<div class="h-full overflow-hidden flex flex-col bg-gray-50">
|
||||
<!-- Loading -->
|
||||
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
||||
|
||||
<template v-else-if="assessment">
|
||||
<!-- Header compact -->
|
||||
<header class="flex-shrink-0 bg-white border-b border-gray-200 px-4 py-3">
|
||||
<header class="flex-shrink-0 bg-white border-b border-gray-200 px-4 py-2">
|
||||
<Breadcrumb :crumbs="[
|
||||
{ label: 'Évaluations', to: '/assessments' },
|
||||
{ label: assessment.title, to: `/assessments/${assessment.id}` },
|
||||
{ label: 'Notation' }
|
||||
]" />
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Gauche : Navigation + Titre -->
|
||||
<!-- Gauche : Titre -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<router-link
|
||||
:to="{ name: 'assessment-detail', params: { id: assessment.id }}"
|
||||
class="text-gray-500 hover:text-gray-700 flex items-center text-sm"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Retour
|
||||
</router-link>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold text-gray-900">{{ assessment.title }}</h1>
|
||||
<p class="text-sm text-gray-500">{{ assessment.class_name }} - Saisie des notes</p>
|
||||
@@ -66,7 +62,7 @@
|
||||
<button
|
||||
@click="saveAll"
|
||||
:disabled="saving"
|
||||
class="px-4 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
class="px-4 py-1.5 text-sm bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors flex items-center disabled:opacity-50"
|
||||
>
|
||||
<svg v-if="saving" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
@@ -79,9 +75,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Guide de saisie -->
|
||||
<div class="mt-3 bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-lg px-4 py-2">
|
||||
<div class="mt-3 bg-gradient-to-r from-primary-50 to-accent-50 border border-primary-200 rounded-lg px-4 py-2">
|
||||
<div class="flex flex-wrap items-center gap-4 text-xs">
|
||||
<span class="font-semibold text-blue-900">Guide :</span>
|
||||
<span class="font-semibold text-primary-900">Guide :</span>
|
||||
<span><strong>Notes</strong> = valeurs décimales (ex: 15.5)</span>
|
||||
<span><strong>Scores</strong> = 0-3 (0=Non acquis, 3=Expert)</span>
|
||||
<span>
|
||||
@@ -98,18 +94,18 @@
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<!-- Header exercices -->
|
||||
<thead class="sticky top-0 z-30">
|
||||
<tr class="bg-gradient-to-r from-indigo-100 to-purple-100 border-b-2 border-indigo-300">
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-700 uppercase sticky left-0 bg-gradient-to-r from-indigo-100 to-purple-100 border-r border-indigo-200 min-w-[200px]">
|
||||
<tr class="bg-gradient-to-r from-primary-100 to-accent-100 border-b-2 border-primary-300">
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-700 uppercase sticky left-0 bg-gradient-to-r from-primary-100 to-accent-100 border-r border-primary-200 min-w-[200px]">
|
||||
Élève
|
||||
</th>
|
||||
<th
|
||||
v-for="(group, exerciseId) in exerciseGroups"
|
||||
:key="exerciseId"
|
||||
:colspan="group.elements.length"
|
||||
class="px-2 py-2 text-center text-sm font-bold text-indigo-900 border-x border-indigo-300"
|
||||
class="px-2 py-2 text-center text-sm font-bold text-primary-900 border-x border-primary-300"
|
||||
>
|
||||
{{ group.title }}
|
||||
<div class="text-xs font-normal text-indigo-700">{{ group.elements.length }} élément(s)</div>
|
||||
<div class="text-xs font-normal text-primary-700">{{ group.elements.length }} élément(s)</div>
|
||||
</th>
|
||||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-700 uppercase min-w-[80px]">
|
||||
Total
|
||||
@@ -127,7 +123,7 @@
|
||||
v-model="studentFilter"
|
||||
type="text"
|
||||
placeholder="Filtrer les élèves..."
|
||||
class="w-full px-2 py-1 pl-7 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
class="w-full px-2 py-1 pl-7 text-xs border border-gray-300 rounded focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
|
||||
@focus="filterFocused = true"
|
||||
@blur="filterFocused = false"
|
||||
/>
|
||||
@@ -169,13 +165,13 @@
|
||||
<div class="mt-1 flex justify-center gap-1 flex-wrap">
|
||||
<span
|
||||
v-if="element.grading_type === 'score'"
|
||||
class="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800"
|
||||
class="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-accent-100 text-accent-800"
|
||||
title="Évaluation par compétences"
|
||||
>
|
||||
Score
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
|
||||
class="inline-flex items-center px-1 py-0.5 rounded text-xs font-medium bg-primary-100 text-primary-800"
|
||||
title="Points maximum"
|
||||
>
|
||||
{{ element.max_points }}pts
|
||||
@@ -201,7 +197,7 @@
|
||||
<span v-if="(element.domain || element.domain_name) && element.skill" class="text-gray-400"> / </span>
|
||||
<span
|
||||
v-if="element.skill"
|
||||
class="text-indigo-600"
|
||||
class="text-primary-600"
|
||||
>
|
||||
{{ element.skill }}
|
||||
</span>
|
||||
@@ -237,7 +233,7 @@
|
||||
</div>
|
||||
<button
|
||||
@click="openQuickComplete(student.id)"
|
||||
class="ml-2 text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded transition-colors"
|
||||
class="ml-2 text-xs bg-primary-100 hover:bg-primary-200 text-primary-700 px-2 py-1 rounded transition-colors"
|
||||
title="Compléter les champs vides"
|
||||
>
|
||||
⚡
|
||||
@@ -261,7 +257,7 @@
|
||||
@focus="setCurrentPosition(studentIdx, elementIdx)"
|
||||
:data-row="studentIdx"
|
||||
:data-col="elementIdx"
|
||||
class="w-full text-xs border rounded py-1 px-1 focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all"
|
||||
class="w-full text-xs border rounded py-1 px-1 focus:ring-2 focus:ring-accent-500 focus:border-accent-500 transition-all"
|
||||
:class="getInputClass(getGrade(student.id, element.id), 'score')"
|
||||
:style="getInputStyle(getGrade(student.id, element.id), 'score')"
|
||||
>
|
||||
@@ -288,7 +284,7 @@
|
||||
:data-row="studentIdx"
|
||||
:data-col="elementIdx"
|
||||
:placeholder="`0-${element.max_points}`"
|
||||
class="w-full text-xs border rounded py-1 px-2 text-center focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all"
|
||||
class="w-full text-xs border rounded py-1 px-2 text-center focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all"
|
||||
:class="getInputClass(getGrade(student.id, element.id), 'notes', element.max_points)"
|
||||
:style="getInputStyle(getGrade(student.id, element.id), 'notes', element.max_points)"
|
||||
/>
|
||||
@@ -313,9 +309,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
@click="resetForm"
|
||||
class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 transition-colors"
|
||||
<button
|
||||
@click="showResetModal = true"
|
||||
class="px-3 py-1 text-xs border border-red-200 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
Réinitialiser
|
||||
</button>
|
||||
@@ -358,7 +354,7 @@
|
||||
</label>
|
||||
<select
|
||||
v-model="quickCompleteValue"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value=".">. - Pas de réponse</option>
|
||||
<option value="d">d - Dispensé</option>
|
||||
@@ -369,7 +365,7 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="quickCompleteOverwrite"
|
||||
class="mr-3 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
class="mr-3 h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span class="text-sm text-gray-700">Écraser les valeurs existantes</span>
|
||||
</label>
|
||||
@@ -380,42 +376,67 @@
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
@click="showQuickComplete = false"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<button @click="showQuickComplete = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="executeQuickComplete"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
<button @click="executeQuickComplete" class="btn btn-primary">
|
||||
Appliquer
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Toast -->
|
||||
<Transition name="toast">
|
||||
<div
|
||||
v-if="toast.show"
|
||||
class="fixed bottom-4 right-4 z-50"
|
||||
>
|
||||
<div
|
||||
class="px-4 py-3 rounded-lg shadow-lg flex items-center"
|
||||
:class="toastClass"
|
||||
>
|
||||
<svg v-if="toast.type === 'success'" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg v-else-if="toast.type === 'error'" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span>{{ toast.message }}</span>
|
||||
<!-- Modal réinitialisation -->
|
||||
<Modal v-model="showResetModal" title="Réinitialiser les notes" size="sm">
|
||||
<p class="text-gray-600">
|
||||
Réinitialiser toutes les notes ? Cette action est irréversible.
|
||||
</p>
|
||||
<template #footer>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button @click="showResetModal = false" class="btn btn-secondary">
|
||||
Annuler
|
||||
</button>
|
||||
<button @click="resetForm" class="btn btn-danger">
|
||||
Réinitialiser
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Modal erreurs de validation -->
|
||||
<Modal v-model="showErrorsModal" title="Valeurs invalides" size="md">
|
||||
<p class="text-gray-600 mb-3">
|
||||
{{ invalidEntries.length }} valeur(s) invalide(s) n'ont pas été sauvegardées :
|
||||
</p>
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Élève</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Élément</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Valeur</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Erreur</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="(entry, idx) in invalidEntries" :key="idx">
|
||||
<td class="px-3 py-2 text-gray-900">{{ entry.studentName }}</td>
|
||||
<td class="px-3 py-2 text-gray-700">{{ entry.elementLabel }}</td>
|
||||
<td class="px-3 py-2 font-mono text-red-600">{{ entry.value }}</td>
|
||||
<td class="px-3 py-2 text-red-500 text-xs">{{ entry.error }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Transition>
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button @click="showErrorsModal = false" class="btn btn-primary">
|
||||
Compris
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -425,90 +446,20 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAssessmentsStore } from '@/stores/assessments'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import classesService from '@/services/classes'
|
||||
import assessmentsService from '@/services/assessments'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import Breadcrumb from '@/components/common/Breadcrumb.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const assessmentsStore = useAssessmentsStore()
|
||||
const configStore = useConfigStore()
|
||||
const notifications = useNotificationsStore()
|
||||
|
||||
// Color interpolation functions
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : { r: 0, g: 0, b: 0 }
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
let h, s, l = (max + min) / 2
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
return { h: h * 360, s: s * 100, l: l * 100 }
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
h /= 360; s /= 100; l /= 100
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1/6) return p + (q - p) * 6 * t
|
||||
if (t < 1/2) return q
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
||||
return p
|
||||
}
|
||||
let r, g, b
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1/3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1/3)
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
|
||||
}
|
||||
|
||||
function interpolateColorHSL(color1, color2, factor) {
|
||||
const rgb1 = hexToRgb(color1)
|
||||
const rgb2 = hexToRgb(color2)
|
||||
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b)
|
||||
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b)
|
||||
|
||||
let deltaH = hsl2.h - hsl1.h
|
||||
if (deltaH > 180) hsl2.h -= 360
|
||||
else if (deltaH < -180) hsl2.h += 360
|
||||
|
||||
const h = ((hsl1.h + (hsl2.h - hsl1.h) * factor) % 360 + 360) % 360
|
||||
const s = hsl1.s + (hsl2.s - hsl1.s) * factor
|
||||
const l = hsl1.l + (hsl2.l - hsl1.l) * factor
|
||||
|
||||
const rgb = hslToRgb(h, s, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
import { interpolateColorHSL } from '@/utils/colors'
|
||||
|
||||
// State
|
||||
const loading = ref(true)
|
||||
@@ -527,13 +478,14 @@ const studentFilter = ref('')
|
||||
const filterFocused = ref(false)
|
||||
const showKeyboardHelp = ref(false)
|
||||
const showQuickComplete = ref(false)
|
||||
const showResetModal = ref(false)
|
||||
const showErrorsModal = ref(false)
|
||||
const invalidEntries = ref([])
|
||||
const quickCompleteStudentId = ref(null)
|
||||
const quickCompleteValue = ref('.')
|
||||
const quickCompleteOverwrite = ref(false)
|
||||
const currentPosition = ref(null)
|
||||
|
||||
// Toast
|
||||
const toast = ref({ show: false, message: '', type: 'success' })
|
||||
|
||||
// Computed
|
||||
const allElements = computed(() => {
|
||||
@@ -597,15 +549,6 @@ const progressColorClass = computed(() => {
|
||||
|
||||
const hasUnsavedChanges = computed(() => unsavedChanges.value.size > 0)
|
||||
|
||||
const toastClass = computed(() => {
|
||||
const classes = {
|
||||
success: 'bg-green-500 text-white',
|
||||
error: 'bg-red-500 text-white',
|
||||
info: 'bg-blue-500 text-white',
|
||||
warning: 'bg-orange-500 text-white'
|
||||
}
|
||||
return classes[toast.value.type] || classes.info
|
||||
})
|
||||
|
||||
// Options d'échelle depuis la config
|
||||
const scaleOptions = computed(() => {
|
||||
@@ -1091,12 +1034,12 @@ async function saveAll() {
|
||||
saving.value = true
|
||||
try {
|
||||
const gradesArray = []
|
||||
const errors = []
|
||||
|
||||
const collectedErrors = []
|
||||
|
||||
for (const key in grades.value) {
|
||||
const [studentId, elementId] = key.split('_').map(Number)
|
||||
const value = grades.value[key]
|
||||
|
||||
|
||||
if (value !== '') {
|
||||
// Valider avant d'ajouter
|
||||
const element = getElementById(elementId)
|
||||
@@ -1104,11 +1047,16 @@ async function saveAll() {
|
||||
const validation = validateGradeValue(value, element.grading_type, element.max_points)
|
||||
if (!validation.valid) {
|
||||
const student = students.value.find(s => s.id === studentId)
|
||||
errors.push(`${student?.last_name || 'Élève'} - ${element.label || element.name}: ${validation.error}`)
|
||||
collectedErrors.push({
|
||||
studentName: `${student?.last_name || 'Élève'} ${student?.first_name || ''}`.trim(),
|
||||
elementLabel: element.label || element.name,
|
||||
value: value,
|
||||
error: validation.error
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
gradesArray.push({
|
||||
student_id: studentId,
|
||||
grading_element_id: elementId,
|
||||
@@ -1116,17 +1064,17 @@ async function saveAll() {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
showToast(`${errors.length} valeur(s) invalide(s) ignorée(s)`, 'warning')
|
||||
console.warn('Erreurs de validation:', errors)
|
||||
|
||||
if (collectedErrors.length > 0) {
|
||||
invalidEntries.value = collectedErrors
|
||||
showErrorsModal.value = true
|
||||
}
|
||||
|
||||
|
||||
if (gradesArray.length > 0) {
|
||||
await assessmentsStore.saveGrades(assessment.value.id, gradesArray)
|
||||
unsavedChanges.value.clear()
|
||||
showToast('Notes sauvegardées avec succès', 'success')
|
||||
} else if (errors.length === 0) {
|
||||
} else if (collectedErrors.length === 0) {
|
||||
showToast('Aucune note à sauvegarder', 'info')
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1137,22 +1085,17 @@ async function saveAll() {
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
if (!confirm('Êtes-vous sûr de vouloir réinitialiser toutes les notes ? Cette action est irréversible.')) {
|
||||
return
|
||||
}
|
||||
|
||||
grades.value = {}
|
||||
unsavedChanges.value.clear()
|
||||
undoStack.value = []
|
||||
showResetModal.value = false
|
||||
showToast('Formulaire réinitialisé', 'info')
|
||||
}
|
||||
|
||||
// Toast
|
||||
// Notification helper
|
||||
function showToast(message, type = 'success') {
|
||||
toast.value = { show: true, message, type }
|
||||
setTimeout(() => {
|
||||
toast.value.show = false
|
||||
}, 3000)
|
||||
const fn = { success: 'success', error: 'error', warning: 'warning', info: 'info' }
|
||||
notifications[fn[type] || 'info'](message)
|
||||
}
|
||||
|
||||
// Protection fermeture
|
||||
@@ -1229,14 +1172,4 @@ onUnmounted(() => {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,74 +1,22 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="px-4 sm:px-6 lg:px-8 py-8">
|
||||
<LoadingSpinner v-if="loading" text="Chargement des résultats..." fullPage />
|
||||
|
||||
<template v-else-if="results">
|
||||
<!-- Header amélioré avec métadonnées -->
|
||||
<div class="bg-gradient-to-r from-success-500 to-emerald-600 rounded-2xl p-8 mb-8 text-white">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">{{ results.assessment_title }}</h1>
|
||||
<p class="text-success-100 mb-4">Résultats de l'évaluation</p>
|
||||
|
||||
<!-- Métadonnées avec icônes -->
|
||||
<div class="flex flex-wrap gap-4 text-sm text-success-100">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>{{ gradedStudents.length }}/{{ results.students_scores?.length || 0 }} élèves évalués</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<span>Moy: {{ calculatedStats?.mean?.toFixed(1) || '-' }}/20</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
<span>Max: {{ calculatedStats?.max?.toFixed(1) || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton retour -->
|
||||
<router-link
|
||||
:to="`/assessments/${results.assessment_id}`"
|
||||
class="btn bg-white/20 hover:bg-white/30 text-white"
|
||||
>
|
||||
Retour
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
:title="results.assessment_title"
|
||||
:subtitle="`Résultats \u00b7 ${gradedStudents.length}/${results.students_scores?.length || 0} élèves évalués`"
|
||||
:breadcrumbs="[{ label: 'Évaluations', to: '/assessments' }, { label: results.assessment_title, to: `/assessments/${results.assessment_id}` }, { label: 'Résultats' }]"
|
||||
/>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-4 mb-8">
|
||||
<div class="card card-body text-center">
|
||||
<p class="text-sm text-gray-500">Évalués</p>
|
||||
<p class="text-2xl font-bold">{{ gradedStudents.length }}<span class="text-sm text-gray-400">/{{ results.students_scores?.length }}</span></p>
|
||||
</div>
|
||||
<div class="card card-body text-center">
|
||||
<p class="text-sm text-gray-500">Moyenne</p>
|
||||
<p class="text-2xl font-bold">{{ calculatedStats?.mean?.toFixed(2) || '-' }}</p>
|
||||
</div>
|
||||
<div class="card card-body text-center">
|
||||
<p class="text-sm text-gray-500">Médiane</p>
|
||||
<p class="text-2xl font-bold">{{ calculatedStats?.median?.toFixed(2) || '-' }}</p>
|
||||
</div>
|
||||
<div class="card card-body text-center">
|
||||
<p class="text-sm text-gray-500">Écart-type</p>
|
||||
<p class="text-2xl font-bold">{{ calculatedStats?.std_dev?.toFixed(2) || '-' }}</p>
|
||||
</div>
|
||||
<div class="card card-body text-center">
|
||||
<p class="text-sm text-gray-500">Min</p>
|
||||
<p class="text-2xl font-bold">{{ calculatedStats?.min?.toFixed(2) || '-' }}</p>
|
||||
</div>
|
||||
<div class="card card-body text-center">
|
||||
<p class="text-sm text-gray-500">Max</p>
|
||||
<p class="text-2xl font-bold">{{ calculatedStats?.max?.toFixed(2) || '-' }}</p>
|
||||
</div>
|
||||
<!-- Statistics bar -->
|
||||
<div class="flex items-center gap-6 px-4 py-3 bg-white rounded-lg border border-gray-200 mb-6 text-sm">
|
||||
<div><span class="text-gray-500">Évalués</span> <span class="font-bold">{{ gradedStudents.length }}<span class="text-gray-400">/{{ results.students_scores?.length }}</span></span></div>
|
||||
<div><span class="text-gray-500">Moy</span> <span class="font-bold">{{ calculatedStats?.mean?.toFixed(2) || '-' }}</span></div>
|
||||
<div><span class="text-gray-500">Med</span> <span class="font-bold">{{ calculatedStats?.median?.toFixed(2) || '-' }}</span></div>
|
||||
<div><span class="text-gray-500">E-T</span> <span class="font-bold">{{ calculatedStats?.std_dev?.toFixed(2) || '-' }}</span></div>
|
||||
<div><span class="text-gray-500">Min</span> <span class="font-bold">{{ calculatedStats?.min?.toFixed(2) || '-' }}</span></div>
|
||||
<div><span class="text-gray-500">Max</span> <span class="font-bold">{{ calculatedStats?.max?.toFixed(2) || '-' }}</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Avertissement si élèves non évalués -->
|
||||
@@ -202,80 +150,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar de sélection (mode sélection activé) -->
|
||||
<div v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="card mb-6 border-2 border-blue-500 bg-blue-50">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-semibold text-blue-900">Mode Sélection Activé</h3>
|
||||
<p class="text-sm text-blue-700">
|
||||
Cliquez sur les cases pour sélectionner les élèves qui recevront leur bilan
|
||||
<span v-if="selectedStudents.length > 0" class="font-semibold">
|
||||
({{ selectedStudents.length }} sélectionné{{selectedStudents.length > 1 ? 's' : ''}})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="toggleSelectAll"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>
|
||||
{{ allSelected ? 'Tout désélectionner' : 'Tout sélectionner' }}
|
||||
</button>
|
||||
<button
|
||||
@click="cancelSelectionMode"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
@click="openSendModal"
|
||||
:disabled="selectedStudents.length === 0"
|
||||
class="btn btn-primary shadow-lg"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': selectedStudents.length === 0 }"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Envoyer à {{ selectedStudents.length }} élève{{selectedStudents.length > 1 ? 's' : ''}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Student scores table -->
|
||||
<div class="card">
|
||||
<div class="card-header flex justify-between items-center">
|
||||
<h2 class="text-lg font-semibold">Détail par élève</h2>
|
||||
|
||||
<!-- Mode Normal : Bouton pour activer la sélection -->
|
||||
<button
|
||||
v-if="!selectionMode && gradedStudentsWithEmail.length > 0"
|
||||
@click="activateSelectionMode"
|
||||
class="btn btn-primary"
|
||||
<button
|
||||
v-if="gradedStudentsWithEmail.length > 0"
|
||||
@click="toggleSelectAll"
|
||||
class="btn btn-sm btn-secondary"
|
||||
>
|
||||
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
📧 Envoyer des bilans
|
||||
{{ allSelected ? 'Tout désélectionner' : 'Tout sélectionner' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="w-16">
|
||||
<th v-if="gradedStudentsWithEmail.length > 0" class="w-16">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
@@ -295,10 +187,10 @@
|
||||
:key="student.student_id"
|
||||
:class="{
|
||||
'bg-gray-50 opacity-60': !isStudentGraded(student),
|
||||
'bg-blue-50 border-l-4 border-blue-500': selectionMode && isSelected(student.student_id)
|
||||
'bg-blue-50 border-l-4 border-blue-500': isSelected(student.student_id)
|
||||
}"
|
||||
>
|
||||
<td v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="w-16">
|
||||
<td v-if="gradedStudentsWithEmail.length > 0" class="w-16">
|
||||
<input
|
||||
v-if="isStudentGraded(student) && student.email"
|
||||
type="checkbox"
|
||||
@@ -379,6 +271,31 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Sticky bottom bar for sending emails -->
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="selectedStudents.length > 0"
|
||||
class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg z-40 px-6 py-3"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-600">
|
||||
<strong>{{ selectedStudents.length }}</strong> élève{{ selectedStudents.length > 1 ? 's' : '' }} sélectionné{{ selectedStudents.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<button @click="selectedStudents = []" class="text-sm text-gray-500 hover:text-gray-700">
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="openSendModal"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Envoyer les bilans à {{ selectedStudents.length }} élève{{ selectedStudents.length > 1 ? 's' : '' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Modal d'envoi de bilans -->
|
||||
<SendReportsModal
|
||||
v-if="showSendModal"
|
||||
@@ -399,6 +316,7 @@ import { Bar } from 'vue-chartjs'
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend } from 'chart.js'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import SendReportsModal from '@/components/assessment/SendReportsModal.vue'
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
@@ -409,88 +327,8 @@ const configStore = useConfigStore()
|
||||
// État pour la sélection d'élèves et l'envoi d'emails
|
||||
const selectedStudents = ref([])
|
||||
const showSendModal = ref(false)
|
||||
const selectionMode = ref(false) // Mode sélection activé/désactivé
|
||||
|
||||
// Color interpolation functions
|
||||
function hexToRgb(hex) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : { r: 0, g: 0, b: 0 }
|
||||
}
|
||||
|
||||
function rgbToHex(r, g, b) {
|
||||
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
|
||||
}
|
||||
|
||||
function rgbToHsl(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
let h, s, l = (max + min) / 2
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0
|
||||
} else {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break
|
||||
case g: h = (b - r) / d + 2; break
|
||||
case b: h = (r - g) / d + 4; break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
return { h: h * 360, s: s * 100, l: l * 100 }
|
||||
}
|
||||
|
||||
function hslToRgb(h, s, l) {
|
||||
h /= 360; s /= 100; l /= 100
|
||||
const hue2rgb = (p, q, t) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1/6) return p + (q - p) * 6 * t
|
||||
if (t < 1/2) return q
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
||||
return p
|
||||
}
|
||||
let r, g, b
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1/3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1/3)
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
|
||||
}
|
||||
|
||||
function interpolateColorHSL(color1, color2, factor) {
|
||||
const rgb1 = hexToRgb(color1)
|
||||
const rgb2 = hexToRgb(color2)
|
||||
const hsl1 = rgbToHsl(rgb1.r, rgb1.g, rgb1.b)
|
||||
const hsl2 = rgbToHsl(rgb2.r, rgb2.g, rgb2.b)
|
||||
|
||||
let deltaH = hsl2.h - hsl1.h
|
||||
if (deltaH > 180) hsl2.h -= 360
|
||||
else if (deltaH < -180) hsl2.h += 360
|
||||
|
||||
const h = ((hsl1.h + (hsl2.h - hsl1.h) * factor) % 360 + 360) % 360
|
||||
const s = hsl1.s + (hsl2.s - hsl1.s) * factor
|
||||
const l = hsl1.l + (hsl2.l - hsl1.l) * factor
|
||||
|
||||
const rgb = hslToRgb(h, s, l)
|
||||
return rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
}
|
||||
|
||||
function getTextColorForBg(bgColor) {
|
||||
const rgb = hexToRgb(bgColor)
|
||||
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000
|
||||
return brightness > 128 ? '#000000' : '#ffffff'
|
||||
}
|
||||
import { interpolateColorHSL, getTextColorForBg } from '@/utils/colors'
|
||||
|
||||
// Get gradient color based on percentage
|
||||
function getGradientColor(percentage) {
|
||||
@@ -735,23 +573,6 @@ const selectedStudentsData = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// Activer le mode sélection
|
||||
function activateSelectionMode() {
|
||||
selectionMode.value = true
|
||||
selectedStudents.value = [] // Réinitialiser la sélection
|
||||
}
|
||||
|
||||
// Annuler le mode sélection
|
||||
function cancelSelectionMode() {
|
||||
selectionMode.value = false
|
||||
selectedStudents.value = []
|
||||
}
|
||||
|
||||
// Vider la sélection
|
||||
function clearSelection() {
|
||||
selectedStudents.value = []
|
||||
}
|
||||
|
||||
// Vérifier si un élève est sélectionné
|
||||
function isSelected(studentId) {
|
||||
return selectedStudents.value.includes(studentId)
|
||||
@@ -776,7 +597,6 @@ function openSendModal() {
|
||||
function handleReportsSent(result) {
|
||||
showSendModal.value = false
|
||||
selectedStudents.value = []
|
||||
selectionMode.value = false // Désactiver le mode sélection après envoi
|
||||
// Le modal affiche déjà les résultats, pas besoin de notification supplémentaire
|
||||
}
|
||||
|
||||
@@ -801,3 +621,14 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Hero section -->
|
||||
<div class="bg-gradient-to-r from-success-500 to-emerald-600 rounded-2xl p-8 mb-8 text-white">
|
||||
<h1 class="text-3xl font-bold mb-2">Élèves</h1>
|
||||
<p class="text-success-100">{{ students.length }} élève(s) au total</p>
|
||||
</div>
|
||||
<PageHeader title="Élèves">
|
||||
<template #meta>
|
||||
<span class="badge badge-primary">{{ students.length }}</span>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="card card-body mb-6">
|
||||
@@ -35,7 +35,12 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="student in students" :key="student.id">
|
||||
<tr
|
||||
v-for="student in students"
|
||||
:key="student.id"
|
||||
:class="student.current_class_id ? 'hover:bg-gray-50 cursor-pointer' : ''"
|
||||
@click="student.current_class_id && $router.push(`/classes/${student.current_class_id}/students`)"
|
||||
>
|
||||
<td class="font-medium">{{ student.last_name }}</td>
|
||||
<td>{{ student.first_name }}</td>
|
||||
<td>
|
||||
@@ -65,10 +70,8 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import studentsService from '@/services/students'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
|
||||
// Icons
|
||||
const SearchIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>' }
|
||||
const AcademicCapIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" /></svg>' }
|
||||
import PageHeader from '@/components/common/PageHeader.vue'
|
||||
import { SearchIcon, AcademicCapIcon } from '@/components/icons'
|
||||
|
||||
const students = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
@@ -22,20 +22,46 @@ export default {
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
},
|
||||
accent: {
|
||||
50: '#faf5ff',
|
||||
100: '#f3e8ff',
|
||||
200: '#e9d5ff',
|
||||
300: '#d8b4fe',
|
||||
400: '#c084fc',
|
||||
500: '#a855f7',
|
||||
600: '#9333ea',
|
||||
700: '#7e22ce',
|
||||
800: '#6b21a8',
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user