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.class_statistics_service import ClassStatisticsService
|
||||
from schemas.student import StudentWithClass, StudentList
|
||||
from schemas.student import StudentWithClass, StudentList, StudentWithEnrollmentInfo, StudentEnrollmentList
|
||||
from schemas.csv_import import (
|
||||
CSVImportResponse,
|
||||
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(
|
||||
class_id: int,
|
||||
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)"),
|
||||
):
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
@@ -194,22 +194,29 @@ async def get_class_students(
|
||||
students = []
|
||||
for enrollment in enrollments:
|
||||
student = enrollment.student
|
||||
is_active = enrollment.departure_date is None
|
||||
students.append(
|
||||
StudentWithClass(
|
||||
StudentWithEnrollmentInfo(
|
||||
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=class_id if enrollment.departure_date is None else None,
|
||||
current_class_name=cls.name if enrollment.departure_date is None else None
|
||||
current_class_id=class_id if is_active 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
|
||||
students.sort(key=lambda s: (s.last_name.lower(), s.first_name.lower()))
|
||||
|
||||
return StudentList(
|
||||
return StudentEnrollmentList(
|
||||
students=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)
|
||||
async def delete_student(
|
||||
student_id: int,
|
||||
|
||||
@@ -36,6 +36,19 @@ class StudentWithClass(StudentRead):
|
||||
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):
|
||||
"""Schema détaillé avec historique d'inscriptions."""
|
||||
|
||||
@@ -49,6 +62,13 @@ class StudentList(BaseSchema):
|
||||
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
|
||||
class EnrollmentBase(BaseSchema):
|
||||
"""Schema de base pour StudentEnrollment."""
|
||||
|
||||
@@ -23,8 +23,12 @@ export const classesService = {
|
||||
|
||||
// Get class students
|
||||
// atDate: optional date string (YYYY-MM-DD) to filter students enrolled at that date
|
||||
async getStudents(id, atDate = null) {
|
||||
const params = atDate ? { at_date: atDate } : {}
|
||||
// includeDeparted: include students who have left the class
|
||||
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 })
|
||||
// API returns { students: [...], total: N }
|
||||
return response.data.students || []
|
||||
|
||||
@@ -27,6 +27,14 @@ export const studentsService = {
|
||||
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
|
||||
async delete(id) {
|
||||
const response = await api.delete(`/students/${id}`)
|
||||
|
||||
@@ -3,16 +3,29 @@
|
||||
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
||||
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<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>
|
||||
<button @click="showAddModal = true" class="btn btn-primary">
|
||||
+ Ajouter un élève
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Students table -->
|
||||
<div class="card overflow-hidden">
|
||||
<table class="table">
|
||||
<thead>
|
||||
@@ -20,54 +33,577 @@
|
||||
<th>Nom</th>
|
||||
<th>Prénom</th>
|
||||
<th>Email</th>
|
||||
<th>Statut</th>
|
||||
<th>Inscrit le</th>
|
||||
<th>Parti le</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</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="{ 'bg-gray-50': !student.is_active }">
|
||||
<!-- Nom -->
|
||||
<td class="font-medium">{{ student.last_name }}</td>
|
||||
|
||||
<!-- Prénom -->
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="students.length === 0" class="p-8 text-center text-gray-500">
|
||||
Aucun élève trouvé
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add modal placeholder -->
|
||||
<Modal v-model="showAddModal" title="Ajouter un élève">
|
||||
<p class="text-gray-500">Formulaire d'ajout d'élève (à implémenter)</p>
|
||||
<!-- Add/Enroll Student Modal -->
|
||||
<Modal v-model="showAddModal" title="Ajouter un élève" size="large">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useClassesStore } from '@/stores/classes'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
import classesService from '@/services/classes'
|
||||
import studentsService from '@/services/students'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const classesStore = useClassesStore()
|
||||
const notifications = useNotificationsStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const classData = ref(null)
|
||||
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 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) {
|
||||
if (!dateStr) return '-'
|
||||
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 () => {
|
||||
try {
|
||||
const id = route.params.id
|
||||
classData.value = await classesStore.fetchClass(id)
|
||||
students.value = await classesService.getStudents(id)
|
||||
await loadStudents()
|
||||
await loadAvailableStudents()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user