fix(ux): improve destructive action safety, accents, navigation and feedback
Replace window.confirm() with Modal dialogs for class deletion and grade reset. Add unsaved-changes guards to AssessmentFormView. Warn before deleting exercises/elements with existing grades. Surface invalid grades in a detailed Modal after save. Replace GradingView local toasts with global notification store. Fix missing French accents across CouncilView, CouncilStudentDetail, and ConfigView. Make dashboard "À corriger" card and student list rows clickable. Add visual hierarchy to assessment detail actions. Add loading overlay to trimester switch. Simplify email sending workflow by removing mode-switch pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -42,7 +42,7 @@
|
|||||||
<div class="xl:col-span-3 space-y-2">
|
<div class="xl:col-span-3 space-y-2">
|
||||||
<!-- Chart -->
|
<!-- Chart -->
|
||||||
<section v-if="chartData.labels.length > 0">
|
<section v-if="chartData.labels.length > 0">
|
||||||
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Parcours sur l'annee</h3>
|
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Parcours sur l'année</h3>
|
||||||
<div class="bg-gray-50 rounded-lg p-2" style="height: 220px">
|
<div class="bg-gray-50 rounded-lg p-2" style="height: 220px">
|
||||||
<Bar :key="student.student_id" :data="chartData" :options="chartOptions" />
|
<Bar :key="student.student_id" :data="chartData" :options="chartOptions" />
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
<div class="xl:col-span-2 space-y-3">
|
<div class="xl:col-span-2 space-y-3">
|
||||||
<!-- Evaluations -->
|
<!-- Evaluations -->
|
||||||
<section v-if="assessmentList.length">
|
<section v-if="assessmentList.length">
|
||||||
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Evaluations</h3>
|
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Évaluations</h3>
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<div
|
<div
|
||||||
v-for="a in assessmentList"
|
v-for="a in assessmentList"
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="competenceList.length">
|
<section v-if="competenceList.length">
|
||||||
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Competences</h3>
|
<h3 class="text-[11px] font-semibold text-gray-500 uppercase tracking-wide mb-1">Compétences</h3>
|
||||||
<div class="bg-gray-50 rounded-lg p-2" :style="{ height: competenceChartHeight }">
|
<div class="bg-gray-50 rounded-lg p-2" :style="{ height: competenceChartHeight }">
|
||||||
<ChartGeneric type="boxplot" :data="competenceBoxPlotData" :options="boxPlotOptions" />
|
<ChartGeneric type="boxplot" :data="competenceBoxPlotData" :options="boxPlotOptions" />
|
||||||
</div>
|
</div>
|
||||||
@@ -182,7 +182,7 @@ const chartData = computed(() => {
|
|||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Eleve',
|
label: 'Élève',
|
||||||
data: studentScores,
|
data: studentScores,
|
||||||
backgroundColor: barBgColors,
|
backgroundColor: barBgColors,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
@@ -302,7 +302,7 @@ const competenceList = computed(() => {
|
|||||||
const meta = lookup[c.competence_id] || {}
|
const meta = lookup[c.competence_id] || {}
|
||||||
return {
|
return {
|
||||||
id: c.competence_id,
|
id: c.competence_id,
|
||||||
name: meta.name || `Competence ${c.competence_id}`,
|
name: meta.name || `Compétence ${c.competence_id}`,
|
||||||
color: meta.color || '#6B7280',
|
color: meta.color || '#6B7280',
|
||||||
pct: (c.total_points_obtained / c.total_points_possible) * 100
|
pct: (c.total_points_obtained / c.total_points_possible) * 100
|
||||||
}
|
}
|
||||||
@@ -358,7 +358,7 @@ const domainBoxPlotData = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'scatter',
|
type: 'scatter',
|
||||||
label: 'Eleve',
|
label: 'Élève',
|
||||||
data: domains.map((d, i) => ({ x: d.pct, y: i })),
|
data: domains.map((d, i) => ({ x: d.pct, y: i })),
|
||||||
pointRadius: 7,
|
pointRadius: 7,
|
||||||
pointStyle: 'rectRot',
|
pointStyle: 'rectRot',
|
||||||
@@ -388,7 +388,7 @@ const competenceBoxPlotData = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'scatter',
|
type: 'scatter',
|
||||||
label: 'Eleve',
|
label: 'Élève',
|
||||||
data: comps.map((c, i) => ({ x: c.pct, y: i })),
|
data: comps.map((c, i) => ({ x: c.pct, y: i })),
|
||||||
pointRadius: 7,
|
pointRadius: 7,
|
||||||
pointStyle: 'rectRot',
|
pointStyle: 'rectRot',
|
||||||
@@ -410,7 +410,7 @@ const boxPlotOptions = {
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
label(ctx) {
|
label(ctx) {
|
||||||
if (ctx.dataset.type === 'scatter') {
|
if (ctx.dataset.type === 'scatter') {
|
||||||
return `Eleve: ${ctx.raw.x.toFixed(0)}%`
|
return `Élève : ${ctx.raw.x.toFixed(0)}%`
|
||||||
}
|
}
|
||||||
const item = ctx.raw
|
const item = ctx.raw
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -24,33 +24,33 @@
|
|||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
<router-link
|
<router-link
|
||||||
:to="`/assessments/${assessment.id}/grading`"
|
:to="`/assessments/${assessment.id}/grading`"
|
||||||
class="card card-body text-center hover:shadow-md transition-shadow"
|
class="card card-body text-center hover:shadow-md transition-shadow bg-primary-50 border-2 border-primary-200"
|
||||||
>
|
>
|
||||||
<PencilIcon class="w-8 h-8 text-primary-600 mx-auto mb-2" />
|
<PencilIcon class="w-8 h-8 text-primary-600 mx-auto mb-2" />
|
||||||
<span class="font-medium">Noter</span>
|
<span class="font-medium">Noter</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
:to="`/assessments/${assessment.id}/results`"
|
:to="`/assessments/${assessment.id}/results`"
|
||||||
class="card card-body text-center hover:shadow-md transition-shadow"
|
class="card card-body text-center hover:shadow-md transition-shadow"
|
||||||
>
|
>
|
||||||
<ChartIcon class="w-8 h-8 text-success-600 mx-auto mb-2" />
|
<ChartIcon class="w-8 h-8 text-success-600 mx-auto mb-2" />
|
||||||
<span class="font-medium">Résultats</span>
|
<span class="font-medium">Résultats</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
:to="`/assessments/${assessment.id}/edit`"
|
:to="`/assessments/${assessment.id}/edit`"
|
||||||
class="card card-body text-center hover:shadow-md transition-shadow"
|
class="card card-body text-center hover:shadow-md transition-shadow"
|
||||||
>
|
>
|
||||||
<CogIcon class="w-8 h-8 text-warning-600 mx-auto mb-2" />
|
<CogIcon class="w-8 h-8 text-warning-600 mx-auto mb-2" />
|
||||||
<span class="font-medium">Modifier</span>
|
<span class="font-medium">Modifier</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="confirmDelete"
|
@click="confirmDelete"
|
||||||
class="card card-body text-center hover:shadow-md transition-shadow"
|
class="card card-body text-center hover:shadow-md transition-shadow border-dashed border-red-200 opacity-75"
|
||||||
>
|
>
|
||||||
<TrashIcon class="w-8 h-8 text-danger-600 mx-auto mb-2" />
|
<TrashIcon class="w-8 h-8 text-danger-600 mx-auto mb-2" />
|
||||||
<span class="font-medium">Supprimer</span>
|
<span class="font-medium">Supprimer</span>
|
||||||
|
|||||||
@@ -277,8 +277,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||||
import { useAssessmentsStore } from '@/stores/assessments'
|
import { useAssessmentsStore } from '@/stores/assessments'
|
||||||
import { useClassesStore } from '@/stores/classes'
|
import { useClassesStore } from '@/stores/classes'
|
||||||
import { useNotificationsStore } from '@/stores/notifications'
|
import { useNotificationsStore } from '@/stores/notifications'
|
||||||
@@ -295,6 +295,7 @@ const isEdit = computed(() => !!route.params.id)
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const classes = computed(() => classesStore.classes)
|
const classes = computed(() => classesStore.classes)
|
||||||
const competences = ref([])
|
const competences = ref([])
|
||||||
|
const formDirty = ref(false)
|
||||||
|
|
||||||
// Computed pour le récapitulatif
|
// Computed pour le récapitulatif
|
||||||
const totalElements = computed(() => {
|
const totalElements = computed(() => {
|
||||||
@@ -368,6 +369,29 @@ const form = ref({
|
|||||||
exercises: []
|
exercises: []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(form, () => {
|
||||||
|
formDirty.value = true
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
onBeforeRouteLeave((to, from, next) => {
|
||||||
|
if (formDirty.value && !submitting.value) {
|
||||||
|
if (confirm('Vous avez des modifications non sauvegardées. Quitter cette page ?')) {
|
||||||
|
next()
|
||||||
|
} else {
|
||||||
|
next(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleBeforeUnload(event) {
|
||||||
|
if (formDirty.value) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.returnValue = 'Vous avez des modifications non sauvegardées.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addExercise() {
|
function addExercise() {
|
||||||
const newOrder = form.value.exercises.length + 1
|
const newOrder = form.value.exercises.length + 1
|
||||||
form.value.exercises.push({
|
form.value.exercises.push({
|
||||||
@@ -387,8 +411,13 @@ function addExercise() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeExercise(idx) {
|
function removeExercise(idx) {
|
||||||
|
const exercise = form.value.exercises[idx]
|
||||||
|
if (isEdit.value && exercise.id) {
|
||||||
|
if (!confirm('Cet exercice contient potentiellement des notes. Supprimer ?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
form.value.exercises.splice(idx, 1)
|
form.value.exercises.splice(idx, 1)
|
||||||
// Renumber exercises
|
|
||||||
form.value.exercises.forEach((ex, i) => {
|
form.value.exercises.forEach((ex, i) => {
|
||||||
ex.order = i + 1
|
ex.order = i + 1
|
||||||
})
|
})
|
||||||
@@ -417,6 +446,12 @@ function addElement(exIdx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeElement(exIdx, elIdx) {
|
function removeElement(exIdx, elIdx) {
|
||||||
|
const element = form.value.exercises[exIdx].grading_elements[elIdx]
|
||||||
|
if (isEdit.value && element.id) {
|
||||||
|
if (!confirm('Cet élément contient potentiellement des notes. Supprimer ?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
form.value.exercises[exIdx].grading_elements.splice(elIdx, 1)
|
form.value.exercises[exIdx].grading_elements.splice(elIdx, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,10 +505,12 @@ async function submit() {
|
|||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await assessmentsStore.updateAssessment(route.params.id, data)
|
await assessmentsStore.updateAssessment(route.params.id, data)
|
||||||
notifications.success('Évaluation modifiée avec succès')
|
notifications.success('Évaluation modifiée avec succès')
|
||||||
|
formDirty.value = false
|
||||||
router.push(`/assessments/${route.params.id}`)
|
router.push(`/assessments/${route.params.id}`)
|
||||||
} else {
|
} else {
|
||||||
const created = await assessmentsStore.createAssessment(data)
|
const created = await assessmentsStore.createAssessment(data)
|
||||||
notifications.success('Évaluation créée avec succès')
|
notifications.success('Évaluation créée avec succès')
|
||||||
|
formDirty.value = false
|
||||||
router.push(`/assessments/${created.id}`)
|
router.push(`/assessments/${created.id}`)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -526,6 +563,13 @@ onMounted(async () => {
|
|||||||
// Add first exercise for new assessment
|
// Add first exercise for new assessment
|
||||||
addExercise()
|
addExercise()
|
||||||
}
|
}
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
// Reset dirty flag after initial load
|
||||||
|
nextTick(() => { formDirty.value = false })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadCompetences() {
|
async function loadCompetences() {
|
||||||
|
|||||||
@@ -121,12 +121,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<ProgressIndicator
|
<ProgressIndicator
|
||||||
:progress="assessment.progress"
|
:progress="assessment.progress"
|
||||||
size="md"
|
size="md"
|
||||||
:clickable="true"
|
|
||||||
@click.prevent="goToGrading(assessment.id)"
|
|
||||||
/>
|
/>
|
||||||
|
<router-link
|
||||||
|
:to="`/assessments/${assessment.id}/grading`"
|
||||||
|
class="text-xs font-medium text-primary-600 hover:text-primary-800 whitespace-nowrap"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
Corriger
|
||||||
|
</router-link>
|
||||||
<ChevronRightIcon class="w-5 h-5 text-gray-400" />
|
<ChevronRightIcon class="w-5 h-5 text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,7 +57,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats principales - Grid 4 colonnes -->
|
<!-- Stats principales - Grid 4 colonnes -->
|
||||||
<div v-if="stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div v-if="stats" class="relative">
|
||||||
|
<div v-if="statsLoading" class="absolute inset-0 bg-white/60 z-10 flex items-center justify-center rounded-xl">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
<!-- Moyenne classe -->
|
<!-- Moyenne classe -->
|
||||||
<div class="bg-white rounded-xl shadow-md p-6 hover:shadow-lg transition-all duration-300">
|
<div class="bg-white rounded-xl shadow-md p-6 hover:shadow-lg transition-all duration-300">
|
||||||
<p class="text-sm text-gray-500 mb-1">Moyenne classe</p>
|
<p class="text-sm text-gray-500 mb-1">Moyenne classe</p>
|
||||||
@@ -96,6 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Domaines et Compétences en 2 colonnes -->
|
<!-- Domaines et Compétences en 2 colonnes -->
|
||||||
<div v-if="stats" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
<div v-if="stats" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
@@ -243,6 +248,7 @@ const stats = ref(null)
|
|||||||
const trimester = ref(null) // null = vision annuelle par défaut
|
const trimester = ref(null) // null = vision annuelle par défaut
|
||||||
const sortColumn = ref('name')
|
const sortColumn = ref('name')
|
||||||
const sortDirection = ref('asc')
|
const sortDirection = ref('asc')
|
||||||
|
const statsLoading = ref(false)
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -257,7 +263,12 @@ async function fetchData() {
|
|||||||
|
|
||||||
async function selectTrimester(t) {
|
async function selectTrimester(t) {
|
||||||
trimester.value = t
|
trimester.value = t
|
||||||
stats.value = await classesStore.fetchClassStats(route.params.id, t)
|
statsLoading.value = true
|
||||||
|
try {
|
||||||
|
stats.value = await classesStore.fetchClassStats(route.params.id, t)
|
||||||
|
} finally {
|
||||||
|
statsLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer la liste des évaluations triée par date
|
// Récupérer la liste des évaluations triée par date
|
||||||
|
|||||||
@@ -97,6 +97,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal v-model="showDeleteModal" title="Confirmer la suppression" size="sm">
|
||||||
|
<p class="text-gray-600">
|
||||||
|
Êtes-vous sûr de vouloir supprimer la classe
|
||||||
|
<strong>{{ classToDelete?.name }}</strong> ?
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
Cette classe contient <strong>{{ classToDelete?.students_count || 0 }}</strong> élève(s).
|
||||||
|
Cette action est irréversible.
|
||||||
|
</p>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<button @click="showDeleteModal = 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="executeDelete" class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700">
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -105,6 +126,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useClassesStore } from '@/stores/classes'
|
import { useClassesStore } from '@/stores/classes'
|
||||||
import SkeletonLoader from '@/components/common/SkeletonLoader.vue'
|
import SkeletonLoader from '@/components/common/SkeletonLoader.vue'
|
||||||
import EmptyState from '@/components/common/EmptyState.vue'
|
import EmptyState from '@/components/common/EmptyState.vue'
|
||||||
|
import Modal from '@/components/common/Modal.vue'
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
|
const PlusIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }
|
||||||
@@ -113,6 +135,8 @@ const ClipboardIcon = { template: '<svg xmlns="http://www.w3.org/2000/svg" fill=
|
|||||||
|
|
||||||
const classesStore = useClassesStore()
|
const classesStore = useClassesStore()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const showDeleteModal = ref(false)
|
||||||
|
const classToDelete = ref(null)
|
||||||
|
|
||||||
const classes = computed(() => classesStore.classes)
|
const classes = computed(() => classesStore.classes)
|
||||||
const totalStudents = computed(() => classesStore.totalStudents)
|
const totalStudents = computed(() => classesStore.totalStudents)
|
||||||
@@ -153,9 +177,16 @@ function getAccentBgClass(className) {
|
|||||||
|
|
||||||
// Confirmation de suppression
|
// Confirmation de suppression
|
||||||
function confirmDelete(cls) {
|
function confirmDelete(cls) {
|
||||||
if (confirm(`Êtes-vous sûr de vouloir supprimer la classe "${cls.name}" ?`)) {
|
classToDelete.value = cls
|
||||||
classesStore.deleteClass(cls.id)
|
showDeleteModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeDelete() {
|
||||||
|
if (classToDelete.value) {
|
||||||
|
classesStore.deleteClass(classToDelete.value.id)
|
||||||
}
|
}
|
||||||
|
showDeleteModal.value = false
|
||||||
|
classToDelete.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -63,10 +63,10 @@ import ConfigEmailTab from '@/components/config/ConfigEmailTab.vue'
|
|||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'general', label: 'General' },
|
{ id: 'general', label: 'Général' },
|
||||||
{ id: 'competences', label: 'Competences' },
|
{ id: 'competences', label: 'Compétences' },
|
||||||
{ id: 'domains', label: 'Domaines' },
|
{ id: 'domains', label: 'Domaines' },
|
||||||
{ id: 'scale', label: 'Echelle' },
|
{ id: 'scale', label: 'Échelle' },
|
||||||
{ id: 'email', label: 'Email' }
|
{ id: 'email', label: 'Email' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -66,14 +66,14 @@
|
|||||||
@next="navigateStudent(1)"
|
@next="navigateStudent(1)"
|
||||||
/>
|
/>
|
||||||
<div v-else class="bg-white rounded-xl shadow-md p-12 text-center">
|
<div v-else class="bg-white rounded-xl shadow-md p-12 text-center">
|
||||||
<p class="text-gray-400">Selectionnez un eleve dans la liste</p>
|
<p class="text-gray-400">Sélectionnez un élève dans la liste</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div v-else-if="currentStats" class="flex-1 flex items-center justify-center">
|
<div v-else-if="currentStats" class="flex-1 flex items-center justify-center">
|
||||||
<p class="text-gray-500">Aucune donnee disponible pour le trimestre {{ trimester }}</p>
|
<p class="text-gray-500">Aucune donnée disponible pour le trimestre {{ trimester }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,9 +57,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<div class="card card-body">
|
<router-link
|
||||||
|
to="/assessments"
|
||||||
|
class="card card-body hover:shadow-md transition-shadow group"
|
||||||
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="p-3 rounded-lg bg-danger-100 text-danger-600">
|
<div class="p-3 rounded-lg bg-danger-100 text-danger-600 group-hover:bg-danger-200 transition-colors">
|
||||||
<PencilIcon class="w-6 h-6" />
|
<PencilIcon class="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
@@ -67,7 +70,7 @@
|
|||||||
<p class="text-2xl font-bold">{{ assessmentsStore.incompleteAssessments.length }}</p>
|
<p class="text-2xl font-bold">{{ assessmentsStore.incompleteAssessments.length }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick actions -->
|
<!-- Quick actions -->
|
||||||
|
|||||||
@@ -313,9 +313,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<button
|
<button
|
||||||
@click="resetForm"
|
@click="showResetModal = true"
|
||||||
class="px-3 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 transition-colors"
|
class="px-3 py-1 text-xs border border-red-200 rounded bg-red-50 text-red-600 hover:bg-red-100 transition-colors"
|
||||||
>
|
>
|
||||||
Réinitialiser
|
Réinitialiser
|
||||||
</button>
|
</button>
|
||||||
@@ -396,26 +396,66 @@
|
|||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- Toast -->
|
<!-- Modal réinitialisation -->
|
||||||
<Transition name="toast">
|
<Modal v-model="showResetModal" title="Réinitialiser les notes" size="sm">
|
||||||
<div
|
<p class="text-gray-600">
|
||||||
v-if="toast.show"
|
Réinitialiser toutes les notes ? Cette action est irréversible.
|
||||||
class="fixed bottom-4 right-4 z-50"
|
</p>
|
||||||
>
|
<template #footer>
|
||||||
<div
|
<div class="flex justify-end space-x-3">
|
||||||
class="px-4 py-3 rounded-lg shadow-lg flex items-center"
|
<button
|
||||||
:class="toastClass"
|
@click="showResetModal = 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"
|
||||||
<svg v-if="toast.type === 'success'" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
Annuler
|
||||||
</svg>
|
</button>
|
||||||
<svg v-else-if="toast.type === 'error'" class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<button
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
@click="resetForm"
|
||||||
</svg>
|
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
|
||||||
<span>{{ toast.message }}</span>
|
>
|
||||||
|
Réinitialiser
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Modal erreurs de validation -->
|
||||||
|
<Modal v-model="showErrorsModal" title="Valeurs invalides" size="md">
|
||||||
|
<p class="text-gray-600 mb-3">
|
||||||
|
{{ invalidEntries.length }} valeur(s) invalide(s) n'ont pas été sauvegardées :
|
||||||
|
</p>
|
||||||
|
<div class="max-h-60 overflow-y-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Élève</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Élément</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Valeur</th>
|
||||||
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500">Erreur</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
<tr v-for="(entry, idx) in invalidEntries" :key="idx">
|
||||||
|
<td class="px-3 py-2 text-gray-900">{{ entry.studentName }}</td>
|
||||||
|
<td class="px-3 py-2 text-gray-700">{{ entry.elementLabel }}</td>
|
||||||
|
<td class="px-3 py-2 font-mono text-red-600">{{ entry.value }}</td>
|
||||||
|
<td class="px-3 py-2 text-red-500 text-xs">{{ entry.error }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
<template #footer>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
@click="showErrorsModal = false"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Compris
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -425,6 +465,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAssessmentsStore } from '@/stores/assessments'
|
import { useAssessmentsStore } from '@/stores/assessments'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
import { useNotificationsStore } from '@/stores/notifications'
|
||||||
import classesService from '@/services/classes'
|
import classesService from '@/services/classes'
|
||||||
import assessmentsService from '@/services/assessments'
|
import assessmentsService from '@/services/assessments'
|
||||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||||
@@ -434,6 +475,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const assessmentsStore = useAssessmentsStore()
|
const assessmentsStore = useAssessmentsStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
|
const notifications = useNotificationsStore()
|
||||||
|
|
||||||
// Color interpolation functions
|
// Color interpolation functions
|
||||||
function hexToRgb(hex) {
|
function hexToRgb(hex) {
|
||||||
@@ -527,13 +569,14 @@ const studentFilter = ref('')
|
|||||||
const filterFocused = ref(false)
|
const filterFocused = ref(false)
|
||||||
const showKeyboardHelp = ref(false)
|
const showKeyboardHelp = ref(false)
|
||||||
const showQuickComplete = ref(false)
|
const showQuickComplete = ref(false)
|
||||||
|
const showResetModal = ref(false)
|
||||||
|
const showErrorsModal = ref(false)
|
||||||
|
const invalidEntries = ref([])
|
||||||
const quickCompleteStudentId = ref(null)
|
const quickCompleteStudentId = ref(null)
|
||||||
const quickCompleteValue = ref('.')
|
const quickCompleteValue = ref('.')
|
||||||
const quickCompleteOverwrite = ref(false)
|
const quickCompleteOverwrite = ref(false)
|
||||||
const currentPosition = ref(null)
|
const currentPosition = ref(null)
|
||||||
|
|
||||||
// Toast
|
|
||||||
const toast = ref({ show: false, message: '', type: 'success' })
|
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const allElements = computed(() => {
|
const allElements = computed(() => {
|
||||||
@@ -597,15 +640,6 @@ const progressColorClass = computed(() => {
|
|||||||
|
|
||||||
const hasUnsavedChanges = computed(() => unsavedChanges.value.size > 0)
|
const hasUnsavedChanges = computed(() => unsavedChanges.value.size > 0)
|
||||||
|
|
||||||
const toastClass = computed(() => {
|
|
||||||
const classes = {
|
|
||||||
success: 'bg-green-500 text-white',
|
|
||||||
error: 'bg-red-500 text-white',
|
|
||||||
info: 'bg-blue-500 text-white',
|
|
||||||
warning: 'bg-orange-500 text-white'
|
|
||||||
}
|
|
||||||
return classes[toast.value.type] || classes.info
|
|
||||||
})
|
|
||||||
|
|
||||||
// Options d'échelle depuis la config
|
// Options d'échelle depuis la config
|
||||||
const scaleOptions = computed(() => {
|
const scaleOptions = computed(() => {
|
||||||
@@ -1091,12 +1125,12 @@ async function saveAll() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const gradesArray = []
|
const gradesArray = []
|
||||||
const errors = []
|
const collectedErrors = []
|
||||||
|
|
||||||
for (const key in grades.value) {
|
for (const key in grades.value) {
|
||||||
const [studentId, elementId] = key.split('_').map(Number)
|
const [studentId, elementId] = key.split('_').map(Number)
|
||||||
const value = grades.value[key]
|
const value = grades.value[key]
|
||||||
|
|
||||||
if (value !== '') {
|
if (value !== '') {
|
||||||
// Valider avant d'ajouter
|
// Valider avant d'ajouter
|
||||||
const element = getElementById(elementId)
|
const element = getElementById(elementId)
|
||||||
@@ -1104,11 +1138,16 @@ async function saveAll() {
|
|||||||
const validation = validateGradeValue(value, element.grading_type, element.max_points)
|
const validation = validateGradeValue(value, element.grading_type, element.max_points)
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
const student = students.value.find(s => s.id === studentId)
|
const student = students.value.find(s => s.id === studentId)
|
||||||
errors.push(`${student?.last_name || 'Élève'} - ${element.label || element.name}: ${validation.error}`)
|
collectedErrors.push({
|
||||||
|
studentName: `${student?.last_name || 'Élève'} ${student?.first_name || ''}`.trim(),
|
||||||
|
elementLabel: element.label || element.name,
|
||||||
|
value: value,
|
||||||
|
error: validation.error
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
gradesArray.push({
|
gradesArray.push({
|
||||||
student_id: studentId,
|
student_id: studentId,
|
||||||
grading_element_id: elementId,
|
grading_element_id: elementId,
|
||||||
@@ -1116,17 +1155,17 @@ async function saveAll() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0) {
|
if (collectedErrors.length > 0) {
|
||||||
showToast(`${errors.length} valeur(s) invalide(s) ignorée(s)`, 'warning')
|
invalidEntries.value = collectedErrors
|
||||||
console.warn('Erreurs de validation:', errors)
|
showErrorsModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gradesArray.length > 0) {
|
if (gradesArray.length > 0) {
|
||||||
await assessmentsStore.saveGrades(assessment.value.id, gradesArray)
|
await assessmentsStore.saveGrades(assessment.value.id, gradesArray)
|
||||||
unsavedChanges.value.clear()
|
unsavedChanges.value.clear()
|
||||||
showToast('Notes sauvegardées avec succès', 'success')
|
showToast('Notes sauvegardées avec succès', 'success')
|
||||||
} else if (errors.length === 0) {
|
} else if (collectedErrors.length === 0) {
|
||||||
showToast('Aucune note à sauvegarder', 'info')
|
showToast('Aucune note à sauvegarder', 'info')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1137,22 +1176,17 @@ async function saveAll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
if (!confirm('Êtes-vous sûr de vouloir réinitialiser toutes les notes ? Cette action est irréversible.')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
grades.value = {}
|
grades.value = {}
|
||||||
unsavedChanges.value.clear()
|
unsavedChanges.value.clear()
|
||||||
undoStack.value = []
|
undoStack.value = []
|
||||||
|
showResetModal.value = false
|
||||||
showToast('Formulaire réinitialisé', 'info')
|
showToast('Formulaire réinitialisé', 'info')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toast
|
// Notification helper
|
||||||
function showToast(message, type = 'success') {
|
function showToast(message, type = 'success') {
|
||||||
toast.value = { show: true, message, type }
|
const fn = { success: 'success', error: 'error', warning: 'warning', info: 'info' }
|
||||||
setTimeout(() => {
|
notifications[fn[type] || 'info'](message)
|
||||||
toast.value.show = false
|
|
||||||
}, 3000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protection fermeture
|
// Protection fermeture
|
||||||
@@ -1229,14 +1263,4 @@ onUnmounted(() => {
|
|||||||
transform: translateX(20px);
|
transform: translateX(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-enter-active,
|
|
||||||
.toast-leave-active {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-enter-from,
|
|
||||||
.toast-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -202,80 +202,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Student scores table -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header flex justify-between items-center">
|
<div class="card-header flex justify-between items-center">
|
||||||
<h2 class="text-lg font-semibold">Détail par élève</h2>
|
<h2 class="text-lg font-semibold">Détail par élève</h2>
|
||||||
|
|
||||||
<!-- Mode Normal : Bouton pour activer la sélection -->
|
<button
|
||||||
<button
|
v-if="gradedStudentsWithEmail.length > 0"
|
||||||
v-if="!selectionMode && gradedStudentsWithEmail.length > 0"
|
@click="toggleSelectAll"
|
||||||
@click="activateSelectionMode"
|
class="btn btn-sm btn-secondary"
|
||||||
class="btn btn-primary"
|
|
||||||
>
|
>
|
||||||
<svg class="w-5 h-5 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
{{ allSelected ? 'Tout désélectionner' : 'Tout sélectionner' }}
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="w-16">
|
<th v-if="gradedStudentsWithEmail.length > 0" class="w-16">
|
||||||
<div class="flex items-center gap-1">
|
<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">
|
<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" />
|
<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" />
|
||||||
@@ -295,10 +239,10 @@
|
|||||||
:key="student.student_id"
|
:key="student.student_id"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-gray-50 opacity-60': !isStudentGraded(student),
|
'bg-gray-50 opacity-60': !isStudentGraded(student),
|
||||||
'bg-blue-50 border-l-4 border-blue-500': selectionMode && isSelected(student.student_id)
|
'bg-blue-50 border-l-4 border-blue-500': isSelected(student.student_id)
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<td v-if="selectionMode && gradedStudentsWithEmail.length > 0" class="w-16">
|
<td v-if="gradedStudentsWithEmail.length > 0" class="w-16">
|
||||||
<input
|
<input
|
||||||
v-if="isStudentGraded(student) && student.email"
|
v-if="isStudentGraded(student) && student.email"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -379,6 +323,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Sticky bottom bar for sending emails -->
|
||||||
|
<Transition name="slide-up">
|
||||||
|
<div
|
||||||
|
v-if="selectedStudents.length > 0"
|
||||||
|
class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 shadow-lg z-40 px-6 py-3"
|
||||||
|
>
|
||||||
|
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="text-sm text-gray-600">
|
||||||
|
<strong>{{ selectedStudents.length }}</strong> élève{{ selectedStudents.length > 1 ? 's' : '' }} sélectionné{{ selectedStudents.length > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<button @click="selectedStudents = []" class="text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="openSendModal"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
Envoyer les bilans à {{ selectedStudents.length }} élève{{ selectedStudents.length > 1 ? 's' : '' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- Modal d'envoi de bilans -->
|
<!-- Modal d'envoi de bilans -->
|
||||||
<SendReportsModal
|
<SendReportsModal
|
||||||
v-if="showSendModal"
|
v-if="showSendModal"
|
||||||
@@ -409,7 +378,6 @@ const configStore = useConfigStore()
|
|||||||
// État pour la sélection d'élèves et l'envoi d'emails
|
// État pour la sélection d'élèves et l'envoi d'emails
|
||||||
const selectedStudents = ref([])
|
const selectedStudents = ref([])
|
||||||
const showSendModal = ref(false)
|
const showSendModal = ref(false)
|
||||||
const selectionMode = ref(false) // Mode sélection activé/désactivé
|
|
||||||
|
|
||||||
// Color interpolation functions
|
// Color interpolation functions
|
||||||
function hexToRgb(hex) {
|
function hexToRgb(hex) {
|
||||||
@@ -735,23 +703,6 @@ const selectedStudentsData = computed(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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é
|
// Vérifier si un élève est sélectionné
|
||||||
function isSelected(studentId) {
|
function isSelected(studentId) {
|
||||||
return selectedStudents.value.includes(studentId)
|
return selectedStudents.value.includes(studentId)
|
||||||
@@ -776,7 +727,6 @@ function openSendModal() {
|
|||||||
function handleReportsSent(result) {
|
function handleReportsSent(result) {
|
||||||
showSendModal.value = false
|
showSendModal.value = false
|
||||||
selectedStudents.value = []
|
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
|
// Le modal affiche déjà les résultats, pas besoin de notification supplémentaire
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,3 +751,14 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.slide-up-enter-active,
|
||||||
|
.slide-up-leave-active {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.slide-up-enter-from,
|
||||||
|
.slide-up-leave-to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -35,7 +35,12 @@
|
|||||||
</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="student.current_class_id ? 'hover:bg-gray-50 cursor-pointer' : ''"
|
||||||
|
@click="student.current_class_id && $router.push(`/classes/${student.current_class_id}/students`)"
|
||||||
|
>
|
||||||
<td class="font-medium">{{ student.last_name }}</td>
|
<td class="font-medium">{{ student.last_name }}</td>
|
||||||
<td>{{ student.first_name }}</td>
|
<td>{{ student.first_name }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
Reference in New Issue
Block a user