Extract shared utilities (color functions, icon registry), replace hero banners with compact PageHeader, add TrimesterSelector/ConfirmDialog/ Breadcrumb components, consolidate off-palette colors to design tokens, convert AssessmentListView to table layout, compress ResultsView stats into horizontal bar, and inline ClassFormView as a modal in ClassListView. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
591 lines
21 KiB
Vue
591 lines
21 KiB
Vue
<template>
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
|
|
|
|
<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>
|
|
<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-primary-600 focus:ring-primary-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>
|
|
|
|
<!-- Students table -->
|
|
<div class="card overflow-hidden">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<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" :class="{ 'bg-gray-50': !student.is_active }">
|
|
<!-- Nom -->
|
|
<td class="font-medium">{{ student.last_name }}</td>
|
|
|
|
<!-- Prénom -->
|
|
<td>{{ student.first_name }}</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-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">
|
|
<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-primary-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-primary-600 hover:text-primary-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/Enroll Student Modal -->
|
|
<Modal v-model="showAddModal" title="Ajouter un élève" size="xl">
|
|
<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-primary-500 text-primary-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-primary-500 text-primary-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-primary-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-primary-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-primary-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-primary-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-primary-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-primary-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-primary-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-primary-500"
|
|
placeholder="Ex: Transfert depuis..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex justify-end gap-3 pt-4">
|
|
<button @click="showAddModal = false" class="btn btn-secondary">
|
|
Annuler
|
|
</button>
|
|
<button @click="enrollStudent" :disabled="!canEnroll" class="btn btn-primary 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-primary-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-primary-500"
|
|
placeholder="Ex: Déménagement, transfert..."
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-3 pt-4">
|
|
<button @click="showDepartureModal = false" class="btn btn-secondary">
|
|
Annuler
|
|
</button>
|
|
<button @click="confirmDeparture" :disabled="!departureDate" class="btn btn-danger 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-primary-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-primary-500"
|
|
placeholder="Ex: Retour après absence..."
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-3 pt-4">
|
|
<button @click="showReenrollModal = false" class="btn btn-secondary">
|
|
Annuler
|
|
</button>
|
|
<button @click="confirmReenroll" :disabled="!reenrollDate" class="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed">
|
|
Réinscrire
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
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'
|
|
import Breadcrumb from '@/components/common/Breadcrumb.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')
|
|
}
|
|
}
|
|
|
|
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')
|
|
}
|
|
}
|
|
|
|
// 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')
|
|
}
|
|
}
|
|
|
|
// 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')
|
|
}
|
|
}
|
|
|
|
// 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')
|
|
}
|
|
}
|
|
|
|
// 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')
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const id = route.params.id
|
|
classData.value = await classesStore.fetchClass(id)
|
|
await loadStudents()
|
|
await loadAvailableStudents()
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
</script>
|