feat(mail): restauration de l'envoie de mail
All checks were successful
Build and Publish Docker Images / Build Frontend Image (push) Successful in 2m56s
Build and Publish Docker Images / Build Backend Image (push) Successful in 3m5s
Build and Publish Docker Images / Build Summary (push) Successful in 3s

This commit is contained in:
2025-12-04 06:04:13 +01:00
parent 08c8ee4931
commit f76b033d55
11 changed files with 1189 additions and 184 deletions

View 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...&#10;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>

View File

@@ -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">

View File

@@ -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