feat(mail): restauration de l'envoie de mail
This commit is contained in:
267
frontend/src/components/assessment/SendReportsModal.vue
Normal file
267
frontend/src/components/assessment/SendReportsModal.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<Modal :model-value="true" @close="handleClose" size="xl">
|
||||
<template #header>
|
||||
<h3 class="text-xl font-semibold text-gray-900">
|
||||
📧 Envoyer les bilans par email
|
||||
</h3>
|
||||
</template>
|
||||
|
||||
<!-- Phase 1: Configuration de l'envoi -->
|
||||
<div v-if="!sending && !results" class="space-y-6">
|
||||
<!-- Liste des élèves sélectionnés -->
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 mb-3">
|
||||
{{ selectedStudents.length }} élève(s) sélectionné(s)
|
||||
</h4>
|
||||
<div class="max-h-40 overflow-y-auto bg-gray-50 rounded-lg p-4 space-y-2">
|
||||
<div
|
||||
v-for="student in selectedStudents"
|
||||
:key="student.id"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span class="font-medium text-gray-700">{{ student.name }}</span>
|
||||
<span class="text-gray-500">{{ student.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message personnalisé -->
|
||||
<div>
|
||||
<label for="custom-message" class="label">
|
||||
Message personnalisé (optionnel)
|
||||
</label>
|
||||
<textarea
|
||||
id="custom-message"
|
||||
v-model="customMessage"
|
||||
rows="4"
|
||||
placeholder="Ajoutez un commentaire pour vos élèves... Exemple: Bon travail cette semaine ! Continuez vos efforts pour le prochain contrôle."
|
||||
class="input"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Ce message apparaîtra dans le bilan envoyé à chaque élève
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 2: Envoi en cours -->
|
||||
<div v-if="sending" class="py-12">
|
||||
<div class="text-center space-y-6">
|
||||
<!-- Spinner -->
|
||||
<div class="flex justify-center">
|
||||
<div class="animate-spin rounded-full h-16 w-16 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-gray-900 mb-2">
|
||||
Envoi en cours...
|
||||
</h4>
|
||||
<p class="text-gray-600">
|
||||
{{ sentCount }}/{{ selectedStudents.length }} bilan(s) envoyé(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
class="bg-primary-600 h-full transition-all duration-300 ease-out"
|
||||
:style="{ width: progressPercentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-2">{{ progressPercentage }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase 3: Résultats -->
|
||||
<div v-if="results" class="space-y-6">
|
||||
<!-- Résumé avec cards colorées -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
|
||||
<div class="text-4xl font-bold text-green-700 mb-2">
|
||||
{{ results.total_sent }}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-green-600">
|
||||
✅ Envoyé(s) avec succès
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<div class="text-4xl font-bold text-red-700 mb-2">
|
||||
{{ results.total_failed }}
|
||||
</div>
|
||||
<div class="text-sm font-medium text-red-600">
|
||||
❌ Échec(s)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message global -->
|
||||
<div
|
||||
class="p-4 rounded-lg"
|
||||
:class="results.success ? 'bg-green-50 border border-green-200' : 'bg-amber-50 border border-amber-200'"
|
||||
>
|
||||
<p class="text-sm font-medium" :class="results.success ? 'text-green-800' : 'text-amber-800'">
|
||||
{{ results.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Détails par élève -->
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 mb-3">Détails par élève</h4>
|
||||
<div class="max-h-80 overflow-y-auto space-y-2">
|
||||
<div
|
||||
v-for="result in results.results"
|
||||
:key="result.student_id"
|
||||
class="flex items-start gap-3 p-3 rounded-lg border"
|
||||
:class="result.success
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'"
|
||||
>
|
||||
<!-- Icône de statut -->
|
||||
<span class="text-2xl flex-shrink-0">
|
||||
{{ result.success ? '✅' : '❌' }}
|
||||
</span>
|
||||
|
||||
<!-- Informations -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium" :class="result.success ? 'text-green-900' : 'text-red-900'">
|
||||
{{ result.student_name }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 truncate">
|
||||
{{ result.email || 'Pas d\'email' }}
|
||||
</p>
|
||||
<p v-if="!result.success" class="text-xs text-red-600 mt-1">
|
||||
{{ result.error_message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<!-- Boutons selon la phase -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
v-if="!sending"
|
||||
@click="handleClose"
|
||||
class="btn"
|
||||
:disabled="sending"
|
||||
>
|
||||
{{ results ? 'Fermer' : 'Annuler' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!results && !sending"
|
||||
@click="sendReports"
|
||||
class="btn btn-primary"
|
||||
:disabled="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 les bilans
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import { assessmentsService } from '@/services/assessments'
|
||||
import { useNotificationsStore } from '@/stores/notifications'
|
||||
|
||||
const props = defineProps({
|
||||
assessmentId: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
selectedStudents: {
|
||||
type: Array,
|
||||
required: true,
|
||||
// Chaque élément: { id, name, email }
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'sent'])
|
||||
|
||||
const notifications = useNotificationsStore()
|
||||
|
||||
// État du composant
|
||||
const customMessage = ref('')
|
||||
const sending = ref(false)
|
||||
const sentCount = ref(0)
|
||||
const results = ref(null)
|
||||
|
||||
// Progress bar
|
||||
const progressPercentage = computed(() => {
|
||||
if (!sending.value || props.selectedStudents.length === 0) return 0
|
||||
return Math.round((sentCount.value / props.selectedStudents.length) * 100)
|
||||
})
|
||||
|
||||
// Méthode d'envoi
|
||||
async function sendReports() {
|
||||
if (props.selectedStudents.length === 0) return
|
||||
|
||||
sending.value = true
|
||||
sentCount.value = 0
|
||||
results.value = null
|
||||
|
||||
try {
|
||||
// Extraire les IDs des élèves
|
||||
const studentIds = props.selectedStudents.map(s => s.id)
|
||||
|
||||
// Appeler le service d'envoi
|
||||
const response = await assessmentsService.sendReports(
|
||||
props.assessmentId,
|
||||
studentIds,
|
||||
customMessage.value
|
||||
)
|
||||
|
||||
// Afficher les résultats
|
||||
results.value = response
|
||||
sentCount.value = response.total_sent
|
||||
|
||||
// Notification
|
||||
if (response.success) {
|
||||
notifications.success(`${response.total_sent} bilan(s) envoyé(s) avec succès`)
|
||||
} else {
|
||||
notifications.warning(response.message)
|
||||
}
|
||||
|
||||
// Émettre l'événement de succès
|
||||
emit('sent', response)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'envoi:', error)
|
||||
|
||||
// Afficher une erreur générale
|
||||
const errorMessage = error.response?.data?.detail || 'Erreur lors de l\'envoi des bilans'
|
||||
notifications.error(errorMessage)
|
||||
|
||||
results.value = {
|
||||
success: false,
|
||||
total_sent: 0,
|
||||
total_failed: props.selectedStudents.length,
|
||||
results: props.selectedStudents.map(s => ({
|
||||
student_id: s.id,
|
||||
student_name: s.name,
|
||||
email: s.email,
|
||||
success: false,
|
||||
error_message: errorMessage
|
||||
})),
|
||||
message: errorMessage
|
||||
}
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
// Bloquer la fermeture pendant l'envoi
|
||||
if (sending.value) return
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
@@ -144,7 +144,7 @@
|
||||
</template>
|
||||
|
||||
<!-- Add/Enroll Student Modal -->
|
||||
<Modal v-model="showAddModal" title="Ajouter un élève" size="large">
|
||||
<Modal v-model="showAddModal" title="Ajouter un élève" size="xl">
|
||||
<div class="space-y-4">
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-gray-200">
|
||||
|
||||
@@ -202,30 +202,126 @@
|
||||
</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">
|
||||
<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"
|
||||
>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="selectionMode && 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" />
|
||||
</svg>
|
||||
</div>
|
||||
</th>
|
||||
<th>Élève</th>
|
||||
<th>Email</th>
|
||||
<th class="text-center">Score</th>
|
||||
<th class="text-center">%</th>
|
||||
<th class="text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr
|
||||
v-for="student in results.students_scores"
|
||||
:key="student.student_id"
|
||||
:class="{ 'bg-gray-50 opacity-60': !isStudentGraded(student) }"
|
||||
:class="{
|
||||
'bg-gray-50 opacity-60': !isStudentGraded(student),
|
||||
'bg-blue-50 border-l-4 border-blue-500': selectionMode && isSelected(student.student_id)
|
||||
}"
|
||||
>
|
||||
<td v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="w-16">
|
||||
<input
|
||||
v-if="isStudentGraded(student) && student.email"
|
||||
type="checkbox"
|
||||
:value="student.student_id"
|
||||
v-model="selectedStudents"
|
||||
class="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
/>
|
||||
<span
|
||||
v-else-if="isStudentGraded(student) && !student.email"
|
||||
class="text-amber-500"
|
||||
title="Pas d'adresse email"
|
||||
>
|
||||
⚠️
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-medium">
|
||||
{{ student.student_name }}
|
||||
<span v-if="!isStudentGraded(student)" class="ml-2 text-xs text-amber-600">(non évalué)</span>
|
||||
</td>
|
||||
<td class="text-sm text-gray-600">
|
||||
<span v-if="student.email">{{ student.email }}</span>
|
||||
<span v-else class="text-gray-400 italic">Pas d'email</span>
|
||||
</td>
|
||||
<td class="text-center font-bold">
|
||||
<template v-if="isStudentGraded(student)">
|
||||
<span
|
||||
@@ -266,12 +362,31 @@
|
||||
</template>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<button
|
||||
v-if="isStudentGraded(student)"
|
||||
@click="openReportInNewTab(student.student_id)"
|
||||
class="btn btn-sm btn-secondary"
|
||||
title="Ouvrir le bilan dans un nouvel onglet"
|
||||
>
|
||||
👁️ Aperçu
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Modal d'envoi de bilans -->
|
||||
<SendReportsModal
|
||||
v-if="showSendModal"
|
||||
:assessment-id="results.assessment_id"
|
||||
:selected-students="selectedStudentsData"
|
||||
@close="showSendModal = false"
|
||||
@sent="handleReportsSent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -283,6 +398,7 @@ import { useConfigStore } from '@/stores/config'
|
||||
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'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
|
||||
|
||||
@@ -290,6 +406,11 @@ const route = useRoute()
|
||||
const assessmentsStore = useAssessmentsStore()
|
||||
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)
|
||||
@@ -586,6 +707,88 @@ function isStudentGraded(student) {
|
||||
return student.has_grades === true
|
||||
}
|
||||
|
||||
// Computed pour les élèves corrigés avec email
|
||||
const gradedStudentsWithEmail = computed(() => {
|
||||
return gradedStudents.value.filter(s => s.email)
|
||||
})
|
||||
|
||||
// Vérifie si tous les élèves sont sélectionnés
|
||||
const allSelected = computed(() => {
|
||||
if (gradedStudentsWithEmail.value.length === 0) return false
|
||||
return gradedStudentsWithEmail.value.every(s => selectedStudents.value.includes(s.student_id))
|
||||
})
|
||||
|
||||
// Vérifie s'il y a au moins un élève avec email
|
||||
const hasStudentsWithEmail = computed(() => {
|
||||
return gradedStudentsWithEmail.value.length > 0
|
||||
})
|
||||
|
||||
// Données des élèves sélectionnés pour le modal
|
||||
const selectedStudentsData = computed(() => {
|
||||
if (!results.value) return []
|
||||
return results.value.students_scores
|
||||
.filter(s => selectedStudents.value.includes(s.student_id))
|
||||
.map(s => ({
|
||||
id: s.student_id,
|
||||
name: s.student_name,
|
||||
email: s.email
|
||||
}))
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Toggle sélection de tous les élèves
|
||||
function toggleSelectAll() {
|
||||
if (allSelected.value) {
|
||||
selectedStudents.value = []
|
||||
} else {
|
||||
selectedStudents.value = gradedStudentsWithEmail.value.map(s => s.student_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Ouvrir le modal d'envoi
|
||||
function openSendModal() {
|
||||
if (selectedStudents.value.length === 0) return
|
||||
showSendModal.value = true
|
||||
}
|
||||
|
||||
// Gérer le résultat de l'envoi
|
||||
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
|
||||
}
|
||||
|
||||
// Ouvrir l'aperçu d'un bilan dans un nouvel onglet
|
||||
function openReportInNewTab(studentId) {
|
||||
const assessmentId = results.value.assessment_id
|
||||
// Construire l'URL complète avec le préfixe API
|
||||
const baseUrl = window.location.origin // Ex: http://localhost:5173
|
||||
const url = `${baseUrl}/api/v2/assessments/${assessmentId}/preview-report/${studentId}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Charger la config du dégradé si pas déjà chargée
|
||||
|
||||
Reference in New Issue
Block a user