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>
625 lines
21 KiB
Vue
625 lines
21 KiB
Vue
<template>
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
<!-- Header compact -->
|
|
<Breadcrumb :crumbs="isEdit
|
|
? [{ label: 'Évaluations', to: '/assessments' }, { label: form.title || 'Évaluation', to: `/assessments/${route.params.id}` }, { label: 'Modifier' }]
|
|
: [{ label: 'Évaluations', to: '/assessments' }, { label: 'Nouvelle évaluation' }]
|
|
" />
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="flex items-center gap-4">
|
|
<h1 class="text-xl font-semibold text-gray-900">
|
|
{{ isEdit ? 'Modifier l\'évaluation' : 'Nouvelle évaluation' }}
|
|
</h1>
|
|
</div>
|
|
<!-- Actions dans le header -->
|
|
<div class="flex gap-2">
|
|
<router-link
|
|
:to="isEdit ? `/assessments/${route.params.id}` : '/assessments'"
|
|
class="btn btn-secondary btn-sm"
|
|
>
|
|
Annuler
|
|
</router-link>
|
|
<button
|
|
type="submit"
|
|
form="assessment-form"
|
|
class="btn btn-primary btn-sm"
|
|
:disabled="submitting"
|
|
>
|
|
{{ submitting ? 'Enregistrement...' : (isEdit ? 'Modifier' : 'Créer') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="assessment-form" @submit.prevent="submit" class="space-y-4">
|
|
<!-- Section Évaluation -->
|
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
|
<div class="col-span-2">
|
|
<label class="label text-xs">Titre *</label>
|
|
<input v-model="form.title" type="text" class="input w-full text-sm" required />
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label text-xs">Classe *</label>
|
|
<select
|
|
v-model="form.class_group_id"
|
|
class="input w-full text-sm"
|
|
:class="{ 'bg-gray-100 cursor-not-allowed': isEdit }"
|
|
:disabled="isEdit"
|
|
required
|
|
>
|
|
<option value="">Sélectionner...</option>
|
|
<option v-for="cls in classes" :key="cls.id" :value="cls.id">
|
|
{{ cls.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label text-xs">Date *</label>
|
|
<input v-model="form.date" type="date" class="input w-full text-sm" required />
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label text-xs">Trimestre *</label>
|
|
<select v-model="form.trimester" class="input w-full text-sm" required>
|
|
<option :value="1">T1</option>
|
|
<option :value="2">T2</option>
|
|
<option :value="3">T3</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="label text-xs">Coef.</label>
|
|
<input v-model.number="form.coefficient" type="number" step="0.5" min="0.5" class="input w-full text-sm" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Récapitulatif -->
|
|
<div class="py-3 border-y border-gray-200 space-y-3">
|
|
<!-- Ligne principale : total points + stats -->
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-2xl font-bold text-gray-900">{{ totalPoints }}</span>
|
|
<span class="text-sm text-gray-500">points</span>
|
|
</div>
|
|
<div class="flex items-center gap-4 text-xs text-gray-500">
|
|
<span>{{ form.exercises.length }} ex.</span>
|
|
<span>{{ totalElements }} él.</span>
|
|
<span class="text-gray-400">Pts = points | Score = 0-3</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Barre de répartition par compétences -->
|
|
<div v-if="uniqueCompetences > 0 && totalPoints > 0">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span class="text-xs text-gray-500">Compétences</span>
|
|
</div>
|
|
<div class="flex h-8 rounded overflow-hidden bg-gray-100">
|
|
<div
|
|
v-for="(data, name, index) in competencesBreakdown"
|
|
:key="name"
|
|
class="relative group"
|
|
:style="{
|
|
width: (data.points / totalPoints * 100) + '%',
|
|
backgroundColor: competenceColors[index % competenceColors.length]
|
|
}"
|
|
>
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<span v-if="data.points / totalPoints > 0.08" class="text-xs font-semibold truncate px-1.5 py-0.5 bg-black/30 rounded text-white">
|
|
{{ name }} ({{ data.points }})
|
|
</span>
|
|
</div>
|
|
<!-- Tooltip -->
|
|
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-gray-900 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
|
{{ name }}: {{ data.points }} pts ({{ Math.round(data.points / totalPoints * 100) }}%)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Barre de répartition par domaines -->
|
|
<div v-if="uniqueDomains > 0 && totalPoints > 0">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<span class="text-xs text-gray-500">Domaines</span>
|
|
</div>
|
|
<div class="flex h-8 rounded overflow-hidden bg-gray-100">
|
|
<div
|
|
v-for="(data, name) in domainsBreakdown"
|
|
:key="name"
|
|
class="relative group"
|
|
:style="{
|
|
width: (data.points / totalPoints * 100) + '%',
|
|
backgroundColor: data.color || '#6b7280'
|
|
}"
|
|
>
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<span v-if="data.points / totalPoints > 0.08" class="text-xs font-semibold truncate px-1.5 py-0.5 bg-black/30 rounded text-white">
|
|
{{ name }} ({{ data.points }})
|
|
</span>
|
|
</div>
|
|
<!-- Tooltip -->
|
|
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-gray-900 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-10">
|
|
{{ name }}: {{ data.points }} pts ({{ Math.round(data.points / totalPoints * 100) }}%)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section Exercices -->
|
|
<div class="space-y-2">
|
|
<!-- Liste des exercices -->
|
|
<div
|
|
v-for="(exercise, exIdx) in form.exercises"
|
|
:key="exIdx"
|
|
class="exercise-item border-l-4 border-l-primary-500 bg-gray-50 rounded-r p-3"
|
|
>
|
|
<!-- Header exercice -->
|
|
<div class="flex items-center gap-2 mb-2">
|
|
<span class="text-xs font-bold text-primary-600">{{ exIdx + 1 }}</span>
|
|
<input
|
|
v-model="exercise.title"
|
|
type="text"
|
|
class="input flex-1 text-sm py-1"
|
|
placeholder="Titre de l'exercice"
|
|
required
|
|
/>
|
|
<input
|
|
v-model="exercise.description"
|
|
type="text"
|
|
class="input flex-1 text-xs py-1"
|
|
placeholder="Description (optionnel)"
|
|
/>
|
|
<span class="text-xs font-semibold text-gray-600 bg-white px-2 py-1 rounded border">
|
|
{{ getExercisePoints(exercise) }} pts
|
|
</span>
|
|
<input
|
|
v-model.number="exercise.order"
|
|
type="number"
|
|
class="input w-14 text-xs py-1 text-center"
|
|
min="1"
|
|
title="Ordre"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="text-gray-400 hover:text-danger-500 text-lg leading-none"
|
|
@click="removeExercise(exIdx)"
|
|
title="Supprimer"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Éléments de notation -->
|
|
<div v-if="exercise.grading_elements.length > 0" class="space-y-1 mb-2">
|
|
<div
|
|
v-for="(element, elIdx) in exercise.grading_elements"
|
|
:key="elIdx"
|
|
class="grading-element-item flex items-center gap-1 bg-white rounded p-1.5"
|
|
>
|
|
<input
|
|
v-model="element.label"
|
|
type="text"
|
|
class="input flex-1 text-xs py-1 min-w-0"
|
|
placeholder="Label"
|
|
required
|
|
/>
|
|
<select v-model="element.skill" class="input w-24 text-xs py-1">
|
|
<option value="">Compét.</option>
|
|
<option
|
|
v-for="comp in competences"
|
|
:key="comp.id"
|
|
:value="comp.name"
|
|
>
|
|
{{ comp.name }}
|
|
</option>
|
|
</select>
|
|
<div class="w-28">
|
|
<DomainAutocomplete
|
|
v-model="element.domain"
|
|
placeholder="Domaine"
|
|
/>
|
|
</div>
|
|
<input
|
|
v-model.number="element.max_points"
|
|
type="number"
|
|
step="0.5"
|
|
min="0"
|
|
class="input w-16 text-xs py-1 text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
placeholder="Pts"
|
|
required
|
|
/>
|
|
<select v-model="element.grading_type" class="input w-16 text-xs py-1" required>
|
|
<option value="notes">Pts</option>
|
|
<option value="score">Score</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
class="text-gray-400 hover:text-danger-500 text-sm px-1"
|
|
@click="removeElement(exIdx, elIdx)"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bouton ajouter élément -->
|
|
<button
|
|
type="button"
|
|
class="w-full py-1.5 border border-dashed border-gray-300 rounded text-xs text-gray-500 hover:border-primary-400 hover:text-primary-600 transition-colors"
|
|
@click="addElement(exIdx)"
|
|
>
|
|
+ Élément
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Message si aucun exercice -->
|
|
<div
|
|
v-if="form.exercises.length === 0"
|
|
class="text-center py-6 text-gray-400"
|
|
>
|
|
<p class="text-sm">Aucun exercice</p>
|
|
</div>
|
|
|
|
<!-- Bouton ajouter exercice -->
|
|
<button
|
|
type="button"
|
|
class="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-primary-400 hover:text-primary-600 transition-colors"
|
|
@click="addExercise"
|
|
>
|
|
+ Ajouter un exercice
|
|
</button>
|
|
</form>
|
|
<ConfirmDialog
|
|
v-model="confirmDialog.show"
|
|
:title="confirmDialog.title"
|
|
:message="confirmDialog.message"
|
|
:confirmLabel="confirmDialog.confirmLabel"
|
|
:variant="confirmDialog.variant"
|
|
@confirm="confirmDialog.onConfirm"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
|
|
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
|
import { useAssessmentsStore } from '@/stores/assessments'
|
|
import { useClassesStore } from '@/stores/classes'
|
|
import { useNotificationsStore } from '@/stores/notifications'
|
|
import DomainAutocomplete from '@/components/assessment/DomainAutocomplete.vue'
|
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
|
import Breadcrumb from '@/components/common/Breadcrumb.vue'
|
|
import configService from '@/services/config'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const assessmentsStore = useAssessmentsStore()
|
|
const classesStore = useClassesStore()
|
|
const notifications = useNotificationsStore()
|
|
|
|
const isEdit = computed(() => !!route.params.id)
|
|
const submitting = ref(false)
|
|
const classes = computed(() => classesStore.classes)
|
|
const competences = ref([])
|
|
const formDirty = ref(false)
|
|
|
|
const confirmDialog = ref({
|
|
show: false,
|
|
title: '',
|
|
message: '',
|
|
confirmLabel: 'Confirmer',
|
|
variant: 'danger',
|
|
onConfirm: () => {}
|
|
})
|
|
|
|
function showConfirm(opts) {
|
|
confirmDialog.value = { show: true, ...opts }
|
|
}
|
|
|
|
// Computed pour le récapitulatif
|
|
const totalElements = computed(() => {
|
|
return form.value.exercises.reduce((sum, ex) => sum + ex.grading_elements.length, 0)
|
|
})
|
|
|
|
const totalPoints = computed(() => {
|
|
let total = 0
|
|
for (const ex of form.value.exercises) {
|
|
for (const el of ex.grading_elements) {
|
|
total += el.max_points || 0
|
|
}
|
|
}
|
|
return Math.round(total * 100) / 100
|
|
})
|
|
|
|
// Répartition par compétence avec points
|
|
const competencesBreakdown = computed(() => {
|
|
const breakdown = {}
|
|
for (const ex of form.value.exercises) {
|
|
for (const el of ex.grading_elements) {
|
|
if (el.skill) {
|
|
if (!breakdown[el.skill]) {
|
|
breakdown[el.skill] = { points: 0, count: 0 }
|
|
}
|
|
breakdown[el.skill].points += el.max_points || 0
|
|
breakdown[el.skill].count++
|
|
}
|
|
}
|
|
}
|
|
return breakdown
|
|
})
|
|
|
|
// Répartition par domaine avec points
|
|
const domainsBreakdown = computed(() => {
|
|
const breakdown = {}
|
|
for (const ex of form.value.exercises) {
|
|
for (const el of ex.grading_elements) {
|
|
if (el.domain && el.domain.name) {
|
|
if (!breakdown[el.domain.name]) {
|
|
breakdown[el.domain.name] = { points: 0, count: 0, color: el.domain.color }
|
|
}
|
|
breakdown[el.domain.name].points += el.max_points || 0
|
|
breakdown[el.domain.name].count++
|
|
}
|
|
}
|
|
}
|
|
return breakdown
|
|
})
|
|
|
|
const uniqueCompetences = computed(() => Object.keys(competencesBreakdown.value).length)
|
|
const uniqueDomains = computed(() => Object.keys(domainsBreakdown.value).length)
|
|
|
|
// Couleurs pour les compétences (palette distincte)
|
|
const competenceColors = [
|
|
'#3b82f6', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#6366f1'
|
|
]
|
|
|
|
// Points par exercice
|
|
function getExercisePoints(exercise) {
|
|
return exercise.grading_elements.reduce((sum, el) => sum + (el.max_points || 0), 0)
|
|
}
|
|
|
|
const form = ref({
|
|
title: '',
|
|
description: '',
|
|
date: new Date().toISOString().split('T')[0],
|
|
class_group_id: '',
|
|
trimester: 1,
|
|
coefficient: 1,
|
|
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() {
|
|
const newOrder = form.value.exercises.length + 1
|
|
form.value.exercises.push({
|
|
title: `Exercice ${newOrder}`,
|
|
description: '',
|
|
order: newOrder,
|
|
grading_elements: []
|
|
})
|
|
|
|
// Focus on the new exercise title
|
|
nextTick(() => {
|
|
const inputs = document.querySelectorAll('.exercise-item:last-child input[type="text"]')
|
|
if (inputs.length > 0) {
|
|
inputs[0].focus()
|
|
}
|
|
})
|
|
}
|
|
|
|
function removeExercise(idx) {
|
|
const exercise = form.value.exercises[idx]
|
|
if (isEdit.value && exercise.id) {
|
|
showConfirm({
|
|
title: 'Supprimer l\'exercice',
|
|
message: 'Cet exercice contient potentiellement des notes. Supprimer ?',
|
|
confirmLabel: 'Supprimer',
|
|
variant: 'danger',
|
|
onConfirm: () => doRemoveExercise(idx)
|
|
})
|
|
return
|
|
}
|
|
doRemoveExercise(idx)
|
|
}
|
|
|
|
function doRemoveExercise(idx) {
|
|
form.value.exercises.splice(idx, 1)
|
|
form.value.exercises.forEach((ex, i) => {
|
|
ex.order = i + 1
|
|
})
|
|
}
|
|
|
|
function addElement(exIdx) {
|
|
form.value.exercises[exIdx].grading_elements.push({
|
|
label: '',
|
|
skill: '',
|
|
domain: null,
|
|
max_points: 1,
|
|
grading_type: 'notes',
|
|
description: ''
|
|
})
|
|
|
|
// Focus on the new element label
|
|
nextTick(() => {
|
|
const exercise = document.querySelectorAll('.exercise-item')[exIdx]
|
|
if (exercise) {
|
|
const inputs = exercise.querySelectorAll('.grading-element-item:last-child input[type="text"]')
|
|
if (inputs.length > 0) {
|
|
inputs[0].focus()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
function removeElement(exIdx, elIdx) {
|
|
const element = form.value.exercises[exIdx].grading_elements[elIdx]
|
|
if (isEdit.value && element.id) {
|
|
showConfirm({
|
|
title: 'Supprimer l\'élément',
|
|
message: 'Cet élément contient potentiellement des notes. Supprimer ?',
|
|
confirmLabel: 'Supprimer',
|
|
variant: 'danger',
|
|
onConfirm: () => form.value.exercises[exIdx].grading_elements.splice(elIdx, 1)
|
|
})
|
|
return
|
|
}
|
|
form.value.exercises[exIdx].grading_elements.splice(elIdx, 1)
|
|
}
|
|
|
|
async function submit() {
|
|
// Client-side validation
|
|
if (form.value.exercises.length === 0) {
|
|
notifications.error('Vous devez ajouter au moins un exercice.')
|
|
return
|
|
}
|
|
|
|
const hasEmptyExercises = form.value.exercises.some(ex => ex.grading_elements.length === 0)
|
|
if (hasEmptyExercises) {
|
|
showConfirm({
|
|
title: 'Exercices vides',
|
|
message: 'Certains exercices n\'ont pas d\'éléments de notation. Voulez-vous continuer ?',
|
|
confirmLabel: 'Continuer',
|
|
variant: 'warning',
|
|
onConfirm: () => doSubmit()
|
|
})
|
|
return
|
|
}
|
|
|
|
doSubmit()
|
|
}
|
|
|
|
async function doSubmit() {
|
|
|
|
// Prepare data for API
|
|
const data = {
|
|
title: form.value.title,
|
|
description: form.value.description || null,
|
|
date: form.value.date,
|
|
trimester: form.value.trimester,
|
|
coefficient: form.value.coefficient,
|
|
exercises: form.value.exercises.map(ex => ({
|
|
id: ex.id || null, // ID pour mise à jour
|
|
title: ex.title,
|
|
description: ex.description || null,
|
|
order: ex.order,
|
|
grading_elements: ex.grading_elements.map(el => ({
|
|
id: el.id || null, // ID pour mise à jour (préserve les notes)
|
|
label: el.label,
|
|
skill: el.skill || null,
|
|
domain_id: el.domain?.id || null,
|
|
max_points: el.max_points,
|
|
grading_type: el.grading_type,
|
|
description: el.description || null
|
|
}))
|
|
}))
|
|
}
|
|
|
|
// Ajouter class_group_id seulement pour la création
|
|
if (!isEdit.value) {
|
|
data.class_group_id = form.value.class_group_id
|
|
}
|
|
|
|
submitting.value = true
|
|
try {
|
|
if (isEdit.value) {
|
|
await assessmentsStore.updateAssessment(route.params.id, data)
|
|
notifications.success('Évaluation modifiée avec succès')
|
|
formDirty.value = false
|
|
router.push(`/assessments/${route.params.id}`)
|
|
} else {
|
|
const created = await assessmentsStore.createAssessment(data)
|
|
notifications.success('Évaluation créée avec succès')
|
|
formDirty.value = false
|
|
router.push(`/assessments/${created.id}`)
|
|
}
|
|
} catch (e) {
|
|
const detail = e.response?.data?.detail
|
|
const errorMsg = Array.isArray(detail)
|
|
? detail.map(d => `${d.loc?.join('.')}: ${d.msg}`).join(', ')
|
|
: detail || 'Erreur lors de l\'enregistrement'
|
|
notifications.error(errorMsg)
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
// Load classes and competences
|
|
await Promise.all([
|
|
classesStore.fetchClasses(),
|
|
loadCompetences()
|
|
])
|
|
|
|
if (isEdit.value) {
|
|
// Load existing assessment
|
|
const assessment = await assessmentsStore.fetchAssessment(route.params.id)
|
|
form.value = {
|
|
title: assessment.title,
|
|
description: assessment.description || '',
|
|
date: assessment.date,
|
|
class_group_id: assessment.class_group_id,
|
|
trimester: assessment.trimester,
|
|
coefficient: assessment.coefficient,
|
|
exercises: assessment.exercises.map(ex => ({
|
|
id: ex.id, // Conserver l'ID pour la mise à jour
|
|
title: ex.title,
|
|
description: ex.description || '',
|
|
order: ex.order || 1,
|
|
grading_elements: ex.grading_elements.map(el => ({
|
|
id: el.id, // Conserver l'ID pour préserver les notes
|
|
label: el.label || el.name,
|
|
skill: el.skill || '',
|
|
domain: el.domain_id ? { id: el.domain_id, name: el.domain_name } : null,
|
|
max_points: el.max_points,
|
|
grading_type: el.grading_type,
|
|
description: el.description || ''
|
|
}))
|
|
}))
|
|
}
|
|
} else {
|
|
// Add first exercise for new assessment
|
|
addExercise()
|
|
}
|
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
|
// Reset dirty flag after initial load
|
|
nextTick(() => { formDirty.value = false })
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
|
})
|
|
|
|
async function loadCompetences() {
|
|
try {
|
|
const result = await configService.getCompetences()
|
|
competences.value = result.competences || []
|
|
} catch (error) {
|
|
console.error('Error loading competences:', error)
|
|
}
|
|
}
|
|
</script>
|