Files
notytex/frontend/src/views/AssessmentFormView.vue
Bertrand Benjamin 6cca179346
All checks were successful
Build and Publish Docker Images / Build Frontend Image (push) Successful in 3m3s
Build and Publish Docker Images / Build Backend Image (push) Successful in 3m14s
Build and Publish Docker Images / Build Summary (push) Successful in 3s
refactor(ui): unify frontend around compact, desktop-first design
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>
2026-02-24 09:37:46 +01:00

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"
>
&times;
</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)"
>
&times;
</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>