feat(class): improve class/id/student
This commit is contained in:
@@ -41,7 +41,7 @@ from schemas.class_group import (
|
|||||||
)
|
)
|
||||||
from domain.services.grading_calculator import GradingCalculator
|
from domain.services.grading_calculator import GradingCalculator
|
||||||
from domain.services.class_statistics_service import ClassStatisticsService
|
from domain.services.class_statistics_service import ClassStatisticsService
|
||||||
from schemas.student import StudentWithClass, StudentList
|
from schemas.student import StudentWithClass, StudentList, StudentWithEnrollmentInfo, StudentEnrollmentList
|
||||||
from schemas.csv_import import (
|
from schemas.csv_import import (
|
||||||
CSVImportResponse,
|
CSVImportResponse,
|
||||||
ImportedStudentInfo,
|
ImportedStudentInfo,
|
||||||
@@ -145,7 +145,7 @@ async def get_class(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{class_id}/students", response_model=StudentList)
|
@router.get("/{class_id}/students", response_model=StudentEnrollmentList)
|
||||||
async def get_class_students(
|
async def get_class_students(
|
||||||
class_id: int,
|
class_id: int,
|
||||||
session: AsyncSessionDep,
|
session: AsyncSessionDep,
|
||||||
@@ -153,7 +153,7 @@ async def get_class_students(
|
|||||||
at_date: Optional[str] = Query(None, description="Filtrer les élèves inscrits à cette date (YYYY-MM-DD)"),
|
at_date: Optional[str] = Query(None, description="Filtrer les élèves inscrits à cette date (YYYY-MM-DD)"),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Récupère la liste des étudiants d'une classe.
|
Récupère la liste des étudiants d'une classe avec leurs informations d'inscription.
|
||||||
|
|
||||||
Si at_date est fourni, retourne uniquement les élèves qui étaient inscrits à cette date.
|
Si at_date est fourni, retourne uniquement les élèves qui étaient inscrits à cette date.
|
||||||
"""
|
"""
|
||||||
@@ -194,22 +194,29 @@ async def get_class_students(
|
|||||||
students = []
|
students = []
|
||||||
for enrollment in enrollments:
|
for enrollment in enrollments:
|
||||||
student = enrollment.student
|
student = enrollment.student
|
||||||
|
is_active = enrollment.departure_date is None
|
||||||
students.append(
|
students.append(
|
||||||
StudentWithClass(
|
StudentWithEnrollmentInfo(
|
||||||
id=student.id,
|
id=student.id,
|
||||||
last_name=student.last_name,
|
last_name=student.last_name,
|
||||||
first_name=student.first_name,
|
first_name=student.first_name,
|
||||||
email=student.email,
|
email=student.email,
|
||||||
full_name=f"{student.first_name} {student.last_name}",
|
full_name=f"{student.first_name} {student.last_name}",
|
||||||
current_class_id=class_id if enrollment.departure_date is None else None,
|
current_class_id=class_id if is_active else None,
|
||||||
current_class_name=cls.name if enrollment.departure_date is None else None
|
current_class_name=cls.name if is_active else None,
|
||||||
|
enrollment_id=enrollment.id,
|
||||||
|
enrollment_date=enrollment.enrollment_date,
|
||||||
|
departure_date=enrollment.departure_date,
|
||||||
|
enrollment_reason=enrollment.enrollment_reason,
|
||||||
|
departure_reason=enrollment.departure_reason,
|
||||||
|
is_active=is_active
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Trier par nom de famille puis prénom
|
# Trier par nom de famille puis prénom
|
||||||
students.sort(key=lambda s: (s.last_name.lower(), s.first_name.lower()))
|
students.sort(key=lambda s: (s.last_name.lower(), s.first_name.lower()))
|
||||||
|
|
||||||
return StudentList(
|
return StudentEnrollmentList(
|
||||||
students=students,
|
students=students,
|
||||||
total=len(students)
|
total=len(students)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -281,6 +281,67 @@ async def update_student(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{student_id}/email", response_model=StudentWithClass)
|
||||||
|
async def update_student_email(
|
||||||
|
student_id: int,
|
||||||
|
email: str,
|
||||||
|
session: AsyncSessionDep,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Modifie rapidement l'email d'un étudiant.
|
||||||
|
Endpoint optimisé pour l'édition inline.
|
||||||
|
"""
|
||||||
|
# Récupérer l'étudiant
|
||||||
|
query = (
|
||||||
|
select(Student)
|
||||||
|
.options(
|
||||||
|
selectinload(Student.enrollments).selectinload(StudentEnrollment.class_group)
|
||||||
|
)
|
||||||
|
.where(Student.id == student_id)
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
student = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not student:
|
||||||
|
raise HTTPException(status_code=404, detail="Étudiant non trouvé")
|
||||||
|
|
||||||
|
# Vérifier l'unicité du nouvel email si fourni
|
||||||
|
if email and email != student.email:
|
||||||
|
existing_query = select(Student).where(
|
||||||
|
Student.email == email,
|
||||||
|
Student.id != student_id
|
||||||
|
)
|
||||||
|
existing_result = await session.execute(existing_query)
|
||||||
|
if existing_result.scalar_one_or_none():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Un autre élève avec l'email '{email}' existe déjà"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mettre à jour l'email (permet de le vider avec chaîne vide)
|
||||||
|
student.email = email if email else None
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
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
|
||||||
|
|
||||||
|
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}",
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{student_id}", status_code=204)
|
@router.delete("/{student_id}", status_code=204)
|
||||||
async def delete_student(
|
async def delete_student(
|
||||||
student_id: int,
|
student_id: int,
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ class StudentWithClass(StudentRead):
|
|||||||
current_class_name: Optional[str] = None
|
current_class_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StudentWithEnrollmentInfo(StudentRead):
|
||||||
|
"""Schema avec les informations d'inscription complètes."""
|
||||||
|
|
||||||
|
current_class_id: Optional[int] = None
|
||||||
|
current_class_name: Optional[str] = None
|
||||||
|
enrollment_id: Optional[int] = None
|
||||||
|
enrollment_date: Optional[date] = None
|
||||||
|
departure_date: Optional[date] = None
|
||||||
|
enrollment_reason: Optional[str] = None
|
||||||
|
departure_reason: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
class StudentDetail(StudentWithClass):
|
class StudentDetail(StudentWithClass):
|
||||||
"""Schema détaillé avec historique d'inscriptions."""
|
"""Schema détaillé avec historique d'inscriptions."""
|
||||||
|
|
||||||
@@ -49,6 +62,13 @@ class StudentList(BaseSchema):
|
|||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class StudentEnrollmentList(BaseSchema):
|
||||||
|
"""Schema pour la liste des étudiants avec informations d'inscription."""
|
||||||
|
|
||||||
|
students: List[StudentWithEnrollmentInfo]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
# Schemas pour les inscriptions temporelles
|
# Schemas pour les inscriptions temporelles
|
||||||
class EnrollmentBase(BaseSchema):
|
class EnrollmentBase(BaseSchema):
|
||||||
"""Schema de base pour StudentEnrollment."""
|
"""Schema de base pour StudentEnrollment."""
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ export const classesService = {
|
|||||||
|
|
||||||
// Get class students
|
// Get class students
|
||||||
// atDate: optional date string (YYYY-MM-DD) to filter students enrolled at that date
|
// atDate: optional date string (YYYY-MM-DD) to filter students enrolled at that date
|
||||||
async getStudents(id, atDate = null) {
|
// includeDeparted: include students who have left the class
|
||||||
const params = atDate ? { at_date: atDate } : {}
|
async getStudents(id, atDate = null, includeDeparted = false) {
|
||||||
|
const params = {}
|
||||||
|
if (atDate) params.at_date = atDate
|
||||||
|
if (includeDeparted) params.include_departed = true
|
||||||
|
|
||||||
const response = await api.get(`/classes/${id}/students`, { params })
|
const response = await api.get(`/classes/${id}/students`, { params })
|
||||||
// API returns { students: [...], total: N }
|
// API returns { students: [...], total: N }
|
||||||
return response.data.students || []
|
return response.data.students || []
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ export const studentsService = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Update student email only (fast endpoint)
|
||||||
|
async updateEmail(id, email) {
|
||||||
|
const response = await api.patch(`/students/${id}/email`, null, {
|
||||||
|
params: { email }
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// Delete student
|
// Delete student
|
||||||
async delete(id) {
|
async delete(id) {
|
||||||
const response = await api.delete(`/students/${id}`)
|
const response = await api.delete(`/students/${id}`)
|
||||||
|
|||||||
@@ -3,16 +3,29 @@
|
|||||||
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<!-- Header -->
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">{{ classData?.name }}</h1>
|
<h1 class="text-2xl font-bold">{{ classData?.name }}</h1>
|
||||||
<p class="text-gray-500">{{ students.length }} élève(s)</p>
|
<p class="text-gray-500">{{ activeStudentsCount }} élève(s) actif(s)</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<label class="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg cursor-pointer hover:bg-gray-200 transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="includeDeparted"
|
||||||
|
@change="loadStudents"
|
||||||
|
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-gray-700">Inclure les élèves partis</span>
|
||||||
|
</label>
|
||||||
|
<button @click="showAddModal = true" class="btn btn-primary">
|
||||||
|
+ Ajouter un élève
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button @click="showAddModal = true" class="btn btn-primary">
|
|
||||||
+ Ajouter un élève
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Students table -->
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -20,54 +33,577 @@
|
|||||||
<th>Nom</th>
|
<th>Nom</th>
|
||||||
<th>Prénom</th>
|
<th>Prénom</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
|
<th>Statut</th>
|
||||||
<th>Inscrit le</th>
|
<th>Inscrit le</th>
|
||||||
|
<th>Parti le</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200">
|
<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="{ 'bg-gray-50': !student.is_active }">
|
||||||
|
<!-- Nom -->
|
||||||
<td class="font-medium">{{ student.last_name }}</td>
|
<td class="font-medium">{{ student.last_name }}</td>
|
||||||
|
|
||||||
|
<!-- Prénom -->
|
||||||
<td>{{ student.first_name }}</td>
|
<td>{{ student.first_name }}</td>
|
||||||
<td class="text-gray-500">{{ student.email || '-' }}</td>
|
|
||||||
|
<!-- Email with inline editing -->
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2 group">
|
||||||
|
<template v-if="editingEmailId === student.id">
|
||||||
|
<input
|
||||||
|
ref="emailInput"
|
||||||
|
v-model="editingEmailValue"
|
||||||
|
@blur="saveEmail(student)"
|
||||||
|
@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"
|
||||||
|
placeholder="email@exemple.com"
|
||||||
|
/>
|
||||||
|
<button @click="cancelEditEmail" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<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="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<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"
|
||||||
|
title="Modifier l'email"
|
||||||
|
>
|
||||||
|
<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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Feedback icons -->
|
||||||
|
<span v-if="emailFeedback[student.id] === 'success'" class="text-green-500">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span v-if="emailFeedback[student.id] === 'error'" class="text-red-500">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Statut -->
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
v-if="student.is_active"
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||||
|
>
|
||||||
|
Actif
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||||
|
>
|
||||||
|
Parti
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Inscrit le -->
|
||||||
<td class="text-gray-500">{{ formatDate(student.enrollment_date) }}</td>
|
<td class="text-gray-500">{{ formatDate(student.enrollment_date) }}</td>
|
||||||
|
|
||||||
|
<!-- Parti le -->
|
||||||
|
<td class="text-gray-500">{{ formatDate(student.departure_date) }}</td>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<td class="text-right">
|
||||||
|
<button
|
||||||
|
v-if="student.is_active"
|
||||||
|
@click="openDepartureModal(student)"
|
||||||
|
class="text-sm text-red-600 hover:text-red-800 font-medium"
|
||||||
|
>
|
||||||
|
Sortir de la classe
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
@click="openReenrollModal(student)"
|
||||||
|
class="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
|
||||||
|
>
|
||||||
|
Réinscrire
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-if="students.length === 0" class="p-8 text-center text-gray-500">
|
||||||
|
Aucun élève trouvé
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Add modal placeholder -->
|
<!-- Add/Enroll Student Modal -->
|
||||||
<Modal v-model="showAddModal" title="Ajouter un élève">
|
<Modal v-model="showAddModal" title="Ajouter un élève" size="large">
|
||||||
<p class="text-gray-500">Formulaire d'ajout d'élève (à implémenter)</p>
|
<div class="space-y-4">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-gray-200">
|
||||||
|
<nav class="-mb-px flex space-x-8">
|
||||||
|
<button
|
||||||
|
@click="addMode = 'new'"
|
||||||
|
:class="[
|
||||||
|
'py-4 px-1 border-b-2 font-medium text-sm',
|
||||||
|
addMode === 'new'
|
||||||
|
? 'border-indigo-500 text-indigo-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Nouvel élève
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="addMode = 'existing'"
|
||||||
|
:class="[
|
||||||
|
'py-4 px-1 border-b-2 font-medium text-sm',
|
||||||
|
addMode === 'existing'
|
||||||
|
? 'border-indigo-500 text-indigo-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Élève existant
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Student Form -->
|
||||||
|
<div v-if="addMode === 'new'" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Nom *</label>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Prénom *</label>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date d'inscription *</label>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Raison</label>
|
||||||
|
<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"
|
||||||
|
placeholder="Ex: Nouvelle inscription"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing Student Form -->
|
||||||
|
<div v-if="addMode === 'existing'" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<option :value="null">Sélectionner un élève...</option>
|
||||||
|
<option v-for="student in availableStudents" :key="student.id" :value="student.id">
|
||||||
|
{{ student.last_name }} {{ student.first_name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date d'inscription *</label>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Raison</label>
|
||||||
|
<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"
|
||||||
|
placeholder="Ex: Transfert depuis..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Inscrire
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Departure Modal -->
|
||||||
|
<Modal v-model="showDepartureModal" title="Sortir un élève de la classe">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Vous êtes sur le point de marquer <strong>{{ departureStudent?.first_name }} {{ departureStudent?.last_name }}</strong> comme parti(e) de la classe.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date de départ *</label>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Raison</label>
|
||||||
|
<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"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Confirmer le départ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Re-enroll Modal -->
|
||||||
|
<Modal v-model="showReenrollModal" title="Réinscrire un élève">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Réinscrire <strong>{{ reenrollStudent?.first_name }} {{ reenrollStudent?.last_name }}</strong> dans la classe.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date de réinscription *</label>
|
||||||
|
<input
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Raison</label>
|
||||||
|
<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"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Réinscrire
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useClassesStore } from '@/stores/classes'
|
import { useClassesStore } from '@/stores/classes'
|
||||||
|
import { useNotificationsStore } from '@/stores/notifications'
|
||||||
import classesService from '@/services/classes'
|
import classesService from '@/services/classes'
|
||||||
|
import studentsService from '@/services/students'
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
import Modal from '@/components/common/Modal.vue'
|
import Modal from '@/components/common/Modal.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const classesStore = useClassesStore()
|
const classesStore = useClassesStore()
|
||||||
|
const notifications = useNotificationsStore()
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const classData = ref(null)
|
const classData = ref(null)
|
||||||
const students = ref([])
|
const students = ref([])
|
||||||
|
const includeDeparted = ref(false)
|
||||||
|
|
||||||
|
// Email editing
|
||||||
|
const editingEmailId = ref(null)
|
||||||
|
const editingEmailValue = ref('')
|
||||||
|
const emailFeedback = ref({})
|
||||||
|
const emailInput = ref(null)
|
||||||
|
|
||||||
|
// Add/Enroll student
|
||||||
const showAddModal = ref(false)
|
const showAddModal = ref(false)
|
||||||
|
const addMode = ref('new')
|
||||||
|
const availableStudents = ref([])
|
||||||
|
const newStudent = ref({
|
||||||
|
last_name: '',
|
||||||
|
first_name: '',
|
||||||
|
email: '',
|
||||||
|
enrollment_date: new Date().toISOString().split('T')[0],
|
||||||
|
enrollment_reason: ''
|
||||||
|
})
|
||||||
|
const existingStudent = ref({
|
||||||
|
student_id: null,
|
||||||
|
enrollment_date: new Date().toISOString().split('T')[0],
|
||||||
|
enrollment_reason: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Departure
|
||||||
|
const showDepartureModal = ref(false)
|
||||||
|
const departureStudent = ref(null)
|
||||||
|
const departureDate = ref(new Date().toISOString().split('T')[0])
|
||||||
|
const departureReason = ref('')
|
||||||
|
|
||||||
|
// Re-enroll
|
||||||
|
const showReenrollModal = ref(false)
|
||||||
|
const reenrollStudent = ref(null)
|
||||||
|
const reenrollDate = ref(new Date().toISOString().split('T')[0])
|
||||||
|
const reenrollReason = ref('')
|
||||||
|
|
||||||
|
const activeStudentsCount = computed(() => {
|
||||||
|
return students.value.filter(s => s.is_active).length
|
||||||
|
})
|
||||||
|
|
||||||
|
const canEnroll = computed(() => {
|
||||||
|
if (addMode.value === 'new') {
|
||||||
|
return newStudent.value.last_name && newStudent.value.first_name && newStudent.value.enrollment_date
|
||||||
|
} else {
|
||||||
|
return existingStudent.value.student_id && existingStudent.value.enrollment_date
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr) {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR')
|
return new Date(dateStr).toLocaleDateString('fr-FR')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadStudents() {
|
||||||
|
try {
|
||||||
|
const id = route.params.id
|
||||||
|
students.value = await classesService.getStudents(id, null, includeDeparted.value)
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error('Erreur lors du chargement des élèves')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvailableStudents() {
|
||||||
|
try {
|
||||||
|
const allStudents = await studentsService.getAll()
|
||||||
|
// Filter students without active enrollment
|
||||||
|
availableStudents.value = allStudents.filter(s => !s.current_class_id)
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error('Erreur lors du chargement des élèves disponibles')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email editing functions
|
||||||
|
function startEditEmail(student) {
|
||||||
|
editingEmailId.value = student.id
|
||||||
|
editingEmailValue.value = student.email || ''
|
||||||
|
nextTick(() => {
|
||||||
|
emailInput.value?.[0]?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditEmail() {
|
||||||
|
editingEmailId.value = null
|
||||||
|
editingEmailValue.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEmail(student) {
|
||||||
|
if (editingEmailValue.value === student.email) {
|
||||||
|
cancelEditEmail()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await studentsService.updateEmail(student.id, editingEmailValue.value)
|
||||||
|
student.email = editingEmailValue.value
|
||||||
|
|
||||||
|
// Show success feedback
|
||||||
|
emailFeedback.value[student.id] = 'success'
|
||||||
|
setTimeout(() => {
|
||||||
|
emailFeedback.value[student.id] = null
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
cancelEditEmail()
|
||||||
|
} catch (error) {
|
||||||
|
// Show error feedback
|
||||||
|
emailFeedback.value[student.id] = 'error'
|
||||||
|
setTimeout(() => {
|
||||||
|
emailFeedback.value[student.id] = null
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
notifications.error(error.response?.data?.detail || 'Erreur lors de la mise à jour de l\'email')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enroll student
|
||||||
|
async function enrollStudent() {
|
||||||
|
try {
|
||||||
|
const classId = parseInt(route.params.id)
|
||||||
|
|
||||||
|
if (addMode.value === 'new') {
|
||||||
|
await studentsService.enroll({
|
||||||
|
first_name: newStudent.value.first_name,
|
||||||
|
last_name: newStudent.value.last_name,
|
||||||
|
email: newStudent.value.email || null,
|
||||||
|
class_group_id: classId,
|
||||||
|
enrollment_date: newStudent.value.enrollment_date,
|
||||||
|
enrollment_reason: newStudent.value.enrollment_reason || null
|
||||||
|
})
|
||||||
|
notifications.success('Élève créé et inscrit avec succès')
|
||||||
|
} else {
|
||||||
|
await studentsService.enroll({
|
||||||
|
student_id: existingStudent.value.student_id,
|
||||||
|
class_group_id: classId,
|
||||||
|
enrollment_date: existingStudent.value.enrollment_date,
|
||||||
|
enrollment_reason: existingStudent.value.enrollment_reason || null
|
||||||
|
})
|
||||||
|
notifications.success('Élève inscrit avec succès')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset forms
|
||||||
|
newStudent.value = {
|
||||||
|
last_name: '',
|
||||||
|
first_name: '',
|
||||||
|
email: '',
|
||||||
|
enrollment_date: new Date().toISOString().split('T')[0],
|
||||||
|
enrollment_reason: ''
|
||||||
|
}
|
||||||
|
existingStudent.value = {
|
||||||
|
student_id: null,
|
||||||
|
enrollment_date: new Date().toISOString().split('T')[0],
|
||||||
|
enrollment_reason: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
showAddModal.value = false
|
||||||
|
await loadStudents()
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error(error.response?.data?.detail || 'Erreur lors de l\'inscription')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Departure functions
|
||||||
|
function openDepartureModal(student) {
|
||||||
|
departureStudent.value = student
|
||||||
|
departureDate.value = new Date().toISOString().split('T')[0]
|
||||||
|
departureReason.value = ''
|
||||||
|
showDepartureModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeparture() {
|
||||||
|
try {
|
||||||
|
await studentsService.departure({
|
||||||
|
student_id: departureStudent.value.id,
|
||||||
|
departure_date: departureDate.value,
|
||||||
|
departure_reason: departureReason.value || null
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.success('Départ enregistré avec succès')
|
||||||
|
showDepartureModal.value = false
|
||||||
|
await loadStudents()
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error(error.response?.data?.detail || 'Erreur lors de l\'enregistrement du départ')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enroll functions
|
||||||
|
function openReenrollModal(student) {
|
||||||
|
reenrollStudent.value = student
|
||||||
|
reenrollDate.value = new Date().toISOString().split('T')[0]
|
||||||
|
reenrollReason.value = ''
|
||||||
|
showReenrollModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReenroll() {
|
||||||
|
try {
|
||||||
|
const classId = parseInt(route.params.id)
|
||||||
|
|
||||||
|
await studentsService.enroll({
|
||||||
|
student_id: reenrollStudent.value.id,
|
||||||
|
class_group_id: classId,
|
||||||
|
enrollment_date: reenrollDate.value,
|
||||||
|
enrollment_reason: reenrollReason.value || null
|
||||||
|
})
|
||||||
|
|
||||||
|
notifications.success('Élève réinscrit avec succès')
|
||||||
|
showReenrollModal.value = false
|
||||||
|
await loadStudents()
|
||||||
|
} catch (error) {
|
||||||
|
notifications.error(error.response?.data?.detail || 'Erreur lors de la réinscription')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const id = route.params.id
|
const id = route.params.id
|
||||||
classData.value = await classesStore.fetchClass(id)
|
classData.value = await classesStore.fetchClass(id)
|
||||||
students.value = await classesService.getStudents(id)
|
await loadStudents()
|
||||||
|
await loadAvailableStudents()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user