Files
notytex/templates/assessment_form.html
Bertrand Benjamin 75b03c24ed
All checks were successful
Build and Publish Docker Images / build-and-push (push) Successful in 3m25s
feat: rework assessment creation page
2025-10-29 08:00:53 +01:00

1251 lines
53 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ title }} - Gestion Scolaire{% endblock %}
{% block head %}
<script>
// Désactiver les modules JavaScript automatiques pour cette page
window.notytexDisableAutoload = true;
</script>
{% endblock %}
{% block content %}
<div class="max-w-6xl mx-auto pb-20">
<div class="mb-4">
{% if is_edit %}
<a href="{{ url_for('assessments.detail', id=assessment.id) }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
← Retour à l'évaluation
</a>
{% else %}
<a href="{{ url_for('assessments.list') }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
← Retour aux évaluations
</a>
{% endif %}
</div>
<div class="bg-white shadow-sm rounded-lg border border-gray-200">
<div class="px-6 py-4 border-b border-gray-200">
<h1 class="text-2xl font-bold text-gray-900">{{ title }}</h1>
<p class="text-sm text-gray-600 mt-1">
{% if is_edit %}
Modifiez votre évaluation avec exercices et éléments de notation
{% else %}
Créez votre évaluation avec exercices et éléments de notation
{% endif %}
</p>
</div>
<form id="unified-form" method="POST" class="px-6 py-6">
{{ form.hidden_tag() }}
<!-- Section Informations générales -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Informations générales</h2>
<!-- Ligne 1 : Titre -->
<div class="mb-4">
<label for="{{ form.title.id }}" class="block text-xs font-medium text-gray-600 mb-1">
{{ form.title.label.text }}
</label>
{{ form.title(class="block w-full text-base px-2 py-1 border-0 border-b border-gray-300 hover:border-gray-400 focus:border-blue-500 bg-transparent focus:outline-none transition-colors font-medium") }}
{% if form.title.errors %}
<p class="mt-1 text-sm text-red-600">{{ form.title.errors[0] }}</p>
{% endif %}
</div>
<!-- Ligne 2 : Classe, Date, Trimestre, Coefficient -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div>
<label for="{{ form.class_group_id.id }}" class="block text-xs font-medium text-gray-600 mb-1">
{{ form.class_group_id.label.text }}
</label>
{{ form.class_group_id(class="block w-full text-sm px-2 py-1 border-0 border-b border-gray-300 hover:border-gray-400 focus:border-blue-500 bg-transparent focus:outline-none transition-colors cursor-pointer") }}
{% if form.class_group_id.errors %}
<p class="mt-1 text-sm text-red-600">{{ form.class_group_id.errors[0] }}</p>
{% endif %}
</div>
<div>
<label for="{{ form.date.id }}" class="block text-xs font-medium text-gray-600 mb-1">
{{ form.date.label.text }}
</label>
{{ form.date(class="block w-full text-sm px-2 py-1 border-0 border-b border-gray-300 hover:border-gray-400 focus:border-blue-500 bg-transparent focus:outline-none transition-colors") }}
{% if form.date.errors %}
<p class="mt-1 text-sm text-red-600">{{ form.date.errors[0] }}</p>
{% endif %}
</div>
<div>
<label for="{{ form.trimester.id }}" class="block text-xs font-medium text-gray-600 mb-1">
{{ form.trimester.label.text }}
</label>
{{ form.trimester(class="block w-full text-sm px-2 py-1 border-0 border-b border-gray-300 hover:border-gray-400 focus:border-blue-500 bg-transparent focus:outline-none transition-colors cursor-pointer") }}
{% if form.trimester.errors %}
<p class="mt-1 text-sm text-red-600">{{ form.trimester.errors[0] }}</p>
{% endif %}
</div>
<div>
<label for="{{ form.coefficient.id }}" class="block text-xs font-medium text-gray-600 mb-1">
{{ form.coefficient.label.text }}
</label>
{{ form.coefficient(class="block w-full text-sm px-2 py-1 border-0 border-b border-gray-300 hover:border-gray-400 focus:border-blue-500 bg-transparent focus:outline-none transition-colors", step="0.5") }}
{% if form.coefficient.errors %}
<p class="mt-1 text-sm text-red-600">{{ form.coefficient.errors[0] }}</p>
{% endif %}
</div>
</div>
<!-- Ligne 3 : Description -->
<div>
<label for="{{ form.description.id }}" class="block text-xs font-medium text-gray-600 mb-1">
{{ form.description.label.text }} <span class="text-gray-500 text-xs">(optionnel)</span>
</label>
{{ form.description(class="block w-full text-sm px-2 py-1 border-0 border-b border-gray-300 hover:border-gray-400 focus:border-blue-500 bg-transparent focus:outline-none transition-colors", rows="1") }}
{% if form.description.errors %}
<p class="mt-1 text-sm text-red-600">{{ form.description.errors[0] }}</p>
{% endif %}
</div>
</div>
<!-- Séparateur -->
<div class="border-t-2 border-gray-200 mb-8"></div>
<!-- Section Exercices - Hybrid Design -->
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Exercices et barème</h2>
<div id="exercises-container" class="space-y-6">
<!-- Les exercices seront ajoutés ici dynamiquement -->
</div>
<div id="no-exercises" class="text-center py-8 text-gray-500 border-2 border-dashed border-gray-300 rounded-lg">
<p class="text-sm">Aucun exercice ajouté. Utilisez le bouton ci-dessous pour commencer.</p>
</div>
<div class="mt-6">
<button type="button" id="add-exercise" class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Ajouter un exercice
</button>
</div>
</div>
<!-- Guide rapide compact -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 class="text-sm font-medium text-blue-900 mb-2">💡 Guide rapide</h3>
<p class="text-xs text-blue-800">
<strong>Points :</strong> Notation classique décimale (ex: 2.5 sur 4 points) •
<strong>Score :</strong> Niveaux de compétences 0-3 (0=non acquis, 1=en cours, 2=acquis, 3=expert)
</p>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-6 border-t-2 border-gray-200">
{% if is_edit %}
<a href="{{ url_for('assessments.detail', id=assessment.id) }}" class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2">
Annuler
</a>
<button type="submit" class="inline-flex items-center px-6 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<svg class="w-4 h-4 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"></path>
</svg>
Modifier l'évaluation
</button>
{% else %}
<a href="{{ url_for('assessments.list') }}" class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2">
Annuler
</a>
<button type="submit" class="inline-flex items-center px-6 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<svg class="w-4 h-4 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"></path>
</svg>
Créer l'évaluation
</button>
{% endif %}
</div>
</form>
</div>
</div>
<!-- Bandeau sticky résumé temps réel -->
<div id="summary-banner" class="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-gray-200 shadow-lg px-4 py-3 hidden z-40">
<div class="max-w-6xl mx-auto flex flex-wrap items-center justify-between gap-4">
<div class="flex flex-wrap items-center gap-6 text-sm">
<span class="font-medium">
📊 Total: <span id="total-points" class="text-blue-600 font-bold text-lg">0</span> points
</span>
<span>
🏃 <span id="exercise-count" class="text-green-600 font-semibold">0</span> exercices
</span>
<span>
📝 <span id="element-count" class="text-purple-600 font-semibold">0</span> éléments
</span>
</div>
<div class="flex flex-wrap items-center gap-4 text-xs">
<div id="competence-summary" class="text-orange-600 font-medium">
<!-- Compétences dynamiques -->
</div>
<div id="domain-summary" class="text-cyan-600 font-medium">
<!-- Domaines dynamiques -->
</div>
</div>
</div>
</div>
<!-- Template pour un exercice - Design Hybrid -->
<template id="exercise-template">
<div class="exercise-item mb-8">
<!-- Header exercice avec ligne pointillée -->
<div class="flex items-center justify-between py-3 border-b-2 border-dotted border-gray-400">
<div class="flex items-center space-x-3">
<span class="text-base font-semibold text-gray-700">Exercice <span class="exercise-number"></span></span>
<span class="text-gray-400">·</span>
<input type="text" class="exercise-title text-base font-medium border-0 border-b border-transparent hover:border-gray-300 focus:border-blue-500 bg-transparent focus:outline-none transition-colors px-2 py-1 w-64" placeholder="Titre de l'exercice" required>
</div>
<div class="flex items-center space-x-3">
<span class="exercise-total text-sm font-semibold text-blue-600">0 pts</span>
<button type="button" class="remove-exercise text-red-500 hover:text-red-700 transition-colors p-1">
<svg class="w-5 h-5" 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"></path>
</svg>
</button>
</div>
</div>
<!-- Description exercice (optionnelle) -->
<div class="mt-2 px-3">
<input type="text" class="exercise-description w-full text-sm px-2 py-1 border-0 border-b border-transparent hover:border-gray-300 focus:border-blue-500 bg-transparent focus:outline-none transition-colors text-gray-600" placeholder="Description de l'exercice (optionnel)">
</div>
<!-- Ordre caché (auto-calculé) -->
<input type="hidden" class="exercise-order">
<!-- Tableau des éléments de notation -->
<div class="mt-4">
<!-- Header tableau -->
<div class="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-2 text-xs font-semibold text-gray-600 px-3 pb-2">
<div class="w-12">#</div>
<div>Description</div>
<div class="w-32">Compétence</div>
<div class="w-32">Domaine</div>
<div class="w-20">Points</div>
<div class="w-16">Type</div>
<div class="w-8"></div>
</div>
<!-- Conteneur des éléments -->
<div class="grading-elements-container space-y-2">
<!-- Les éléments seront ajoutés ici -->
</div>
<!-- Message si aucun élément -->
<div class="no-grading-elements text-center py-6 text-gray-400 text-sm">
Aucun élément de notation. Cliquez sur "Ajouter un élément" ci-dessous.
</div>
<!-- Bouton ajouter élément -->
<div class="mt-3 px-3">
<button type="button" class="add-grading-element text-sm text-blue-600 hover:text-blue-800 font-medium transition-colors">
+ Ajouter un élément
</button>
</div>
</div>
</div>
</template>
<!-- Template pour un élément de notation - Design tableau simple -->
<template id="grading-element-template">
<div class="grading-element-item grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-2 items-center px-3 py-2 hover:bg-gray-50 rounded-md transition-colors">
<!-- # -->
<div class="w-12">
<input type="text" class="element-label w-full text-sm px-2 py-1 border-0 border-b border-gray-300 hover:border-gray-400 focus:border-blue-500 bg-transparent focus:outline-none transition-colors" placeholder="1a" required>
</div>
<!-- Description -->
<div>
<input type="text" class="element-description w-full text-sm px-2 py-1 border-0 border-b border-gray-300 hover:border-gray-400 focus:border-blue-500 bg-transparent focus:outline-none transition-colors" placeholder="Description...">
</div>
<!-- Compétence -->
<div class="w-32">
<select class="element-skill w-full text-sm px-2 py-1 border-0 border-b border-gray-300 hover:border-gray-400 focus:border-blue-500 bg-transparent focus:outline-none transition-colors cursor-pointer">
<option value="">Non spécifiée</option>
{% for competence in competences %}
<option value="{{ competence.name }}" data-color="{{ competence.color }}" data-icon="{{ competence.icon }}">
{{ competence.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Domaine avec autocomplétion -->
<div class="w-32 relative">
<input type="text" class="element-domain-input w-full text-sm px-2 py-1 border-0 border-b border-gray-300 hover:border-gray-400 focus:border-blue-500 bg-transparent focus:outline-none transition-colors" placeholder="Domaine..." autocomplete="off">
<input type="hidden" class="element-domain-id">
<div class="element-domain-suggestions absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-32 overflow-y-auto hidden">
<!-- Suggestions dynamiques -->
</div>
</div>
<!-- Points max -->
<div class="w-20">
<input type="number" step="0.1" min="0" class="element-max-points w-full text-sm px-2 py-1 border-0 border-b border-gray-300 hover:border-gray-400 focus:border-blue-500 bg-transparent focus:outline-none transition-colors" placeholder="0" required>
</div>
<!-- Type -->
<div class="w-16">
<select class="element-grading-type w-full text-sm px-2 py-1 border-0 border-b border-gray-300 hover:border-gray-400 focus:border-blue-500 bg-transparent focus:outline-none transition-colors cursor-pointer" required>
<option value="">...</option>
<option value="notes">Pts</option>
<option value="score">Scr</option>
</select>
</div>
<!-- Bouton supprimer -->
<div class="w-8 flex justify-center">
<button type="button" class="remove-grading-element text-gray-400 hover:text-red-600 transition-colors p-1">
<svg class="w-4 h-4" 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"></path>
</svg>
</button>
</div>
</div>
</template>
<!-- Toast container pour notifications -->
<div id="toast-container" class="fixed top-4 right-4 z-50 space-y-2">
<!-- Les toasts seront ajoutés ici dynamiquement -->
</div>
<!-- Modal pour création de domaine -->
<div id="domain-creation-modal" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 hidden">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium text-gray-900">Créer un nouveau domaine</h3>
<button type="button" class="close-modal text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" 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"></path>
</svg>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nom du domaine</label>
<input type="text" id="new-domain-name" class="block w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="Ex: Calcul mental">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Couleur associée</label>
<div class="flex flex-wrap gap-2 mb-2">
<button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400 transition-colors" style="background-color: #3b82f6" data-color="#3b82f6"></button>
<button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400 transition-colors" style="background-color: #10b981" data-color="#10b981"></button>
<button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400 transition-colors" style="background-color: #f59e0b" data-color="#f59e0b"></button>
<button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400 transition-colors" style="background-color: #8b5cf6" data-color="#8b5cf6"></button>
<button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400 transition-colors" style="background-color: #ef4444" data-color="#ef4444"></button>
<button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400 transition-colors" style="background-color: #06b6d4" data-color="#06b6d4"></button>
<button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400 transition-colors" style="background-color: #84cc16" data-color="#84cc16"></button>
<button type="button" class="color-option w-8 h-8 rounded-full border-2 border-gray-300 hover:border-gray-400 transition-colors" style="background-color: #f97316" data-color="#f97316"></button>
</div>
<input type="color" id="custom-color" class="w-full h-10 border border-gray-300 rounded cursor-pointer" value="#3b82f6">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description (optionnel)</label>
<input type="text" id="new-domain-description" class="block w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="Description du domaine...">
</div>
</div>
<div class="flex justify-end space-x-3 mt-5">
<button type="button" class="close-modal px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors">Annuler</button>
<button type="button" id="confirm-domain-creation" class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors">Créer</button>
</div>
</div>
</div>
</div>
<script>
let exerciseCounter = 0;
let availableDomains = [];
let currentDomainInput = null;
let selectedColor = '#3b82f6';
// Variables pour le résumé temps réel
let summaryState = {
totalPoints: 0,
exerciseCount: 0,
elementCount: 0,
byCompetence: {},
byDomain: {}
};
// ============================================================================
// FONCTIONS UTILITAIRES
// ============================================================================
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-orange-500',
info: 'bg-blue-500'
};
const icons = {
success: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>',
error: '<svg class="w-5 h-5" 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"></path></svg>',
warning: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>',
info: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
};
toast.className = `${colors[type]} text-white px-4 py-3 rounded-lg shadow-lg flex items-center space-x-3 min-w-[300px] animate-slide-in`;
toast.innerHTML = `
<div class="flex-shrink-0">${icons[type]}</div>
<div class="flex-1 text-sm font-medium">${message}</div>
<button class="flex-shrink-0 hover:bg-white hover:bg-opacity-20 rounded p-1 transition-colors" onclick="this.parentElement.remove()">
<svg class="w-4 h-4" 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"></path>
</svg>
</button>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(() => toast.remove(), 300);
}, 5000);
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// ============================================================================
// CALCUL DU RÉSUMÉ TEMPS RÉEL
// ============================================================================
function calculateLiveSummary() {
const exercises = collectFormData();
let totalPoints = 0;
let elementCount = 0;
let byCompetence = {};
exercises.forEach(exercise => {
exercise.grading_elements.forEach(element => {
if (element.max_points && element.max_points > 0) {
const points = parseFloat(element.max_points);
totalPoints += points;
elementCount++;
// Grouper par compétence
if (element.skill && element.skill.trim()) {
if (!byCompetence[element.skill]) {
byCompetence[element.skill] = { count: 0, points: 0 };
}
byCompetence[element.skill].count++;
byCompetence[element.skill].points += points;
}
}
});
});
return {
totalPoints: totalPoints,
exerciseCount: exercises.length,
elementCount: elementCount,
byCompetence: byCompetence
};
}
function updateSummaryBanner() {
const summary = calculateLiveSummary();
// Mettre à jour les statistiques principales
document.getElementById('total-points').textContent = summary.totalPoints.toFixed(1);
document.getElementById('exercise-count').textContent = summary.exerciseCount;
document.getElementById('element-count').textContent = summary.elementCount;
// Mettre à jour le résumé compétences
const competenceDiv = document.getElementById('competence-summary');
if (Object.keys(summary.byCompetence).length > 0) {
const competenceList = Object.entries(summary.byCompetence)
.map(([comp, data]) => `${comp}: ${data.count}`)
.join(' • ');
competenceDiv.innerHTML = `🏷️ ${competenceList}`;
} else {
competenceDiv.innerHTML = '';
}
// Afficher/masquer le bandeau selon le contenu
const banner = document.getElementById('summary-banner');
if (summary.exerciseCount > 0 || summary.elementCount > 0) {
banner.classList.remove('hidden');
} else {
banner.classList.add('hidden');
}
}
const debouncedUpdateSummary = debounce(updateSummaryBanner, 300);
// ============================================================================
// COLLECTE DES DONNÉES DU FORMULAIRE
// ============================================================================
function collectFormData() {
const exercisesContainer = document.getElementById('exercises-container');
if (!exercisesContainer) return [];
const exercises = [];
const exerciseItems = exercisesContainer.querySelectorAll('.exercise-item');
exerciseItems.forEach((exerciseItem, index) => {
const titleInput = exerciseItem.querySelector('.exercise-title');
const descriptionInput = exerciseItem.querySelector('.exercise-description');
const orderInput = exerciseItem.querySelector('.exercise-order');
const title = titleInput ? titleInput.value : '';
const description = descriptionInput ? descriptionInput.value : '';
const order = index + 1; // Auto-calculé
if (!title.trim()) return;
const gradingElements = [];
const elementItems = exerciseItem.querySelectorAll('.grading-element-item');
elementItems.forEach(elementItem => {
const label = elementItem.querySelector('.element-label')?.value || '';
const skill = elementItem.querySelector('.element-skill')?.value || '';
const maxPointsInput = elementItem.querySelector('.element-max-points');
const maxPoints = maxPointsInput ? parseFloat(maxPointsInput.value) || 0 : 0;
const gradingType = elementItem.querySelector('.element-grading-type')?.value || '';
const description = elementItem.querySelector('.element-description')?.value || '';
const domainHiddenInput = elementItem.querySelector('.element-domain-id');
const domainTextInput = elementItem.querySelector('.element-domain-input');
if (!label.trim() || maxPoints <= 0 || !gradingType) return;
const elementData = {
label: label.trim(),
skill: skill.trim(),
max_points: maxPoints,
grading_type: gradingType,
description: description.trim(),
domainInput: domainTextInput ? domainTextInput.value.trim() : ''
};
// Gestion du domaine
if (domainHiddenInput && domainHiddenInput.value) {
elementData.domain_id = parseInt(domainHiddenInput.value);
} else if (domainTextInput && domainTextInput.value.trim() && !domainHiddenInput.value) {
elementData.domain_name = domainTextInput.value.trim();
}
gradingElements.push(elementData);
});
exercises.push({
title: title.trim(),
description: description.trim(),
order: order,
grading_elements: gradingElements
});
});
return exercises;
}
// ============================================================================
// GESTION DES DOMAINES
// ============================================================================
async function loadDomains() {
try {
const response = await fetch('/api/domains/');
const data = await response.json();
if (data.success) {
availableDomains = data.domains;
}
} catch (error) {
console.error('Erreur lors du chargement des domaines:', error);
}
}
function setupDomainAutocomplete(container) {
const inputElement = container.querySelector('.element-domain-input');
const hiddenElement = container.querySelector('.element-domain-id');
const suggestionsElement = container.querySelector('.element-domain-suggestions');
if (!inputElement || !hiddenElement || !suggestionsElement) {
return;
}
let debounceTimer = null;
let selectedIndex = -1;
inputElement.addEventListener('input', function() {
const query = this.value.trim();
clearTimeout(debounceTimer);
selectedIndex = -1;
debouncedUpdateSummary();
if (query.length === 0) {
hideSuggestions(suggestionsElement);
hiddenElement.value = '';
return;
}
debounceTimer = setTimeout(() => {
searchAndShowSuggestions(query, inputElement, hiddenElement, suggestionsElement);
}, 300);
});
inputElement.addEventListener('blur', function(e) {
setTimeout(() => {
if (!suggestionsElement.contains(document.activeElement) &&
!suggestionsElement.matches(':hover')) {
hideSuggestions(suggestionsElement);
}
}, 200);
});
inputElement.addEventListener('focus', function() {
if (this.value.trim()) {
searchAndShowSuggestions(this.value.trim(), inputElement, hiddenElement, suggestionsElement);
}
});
// Navigation clavier
inputElement.addEventListener('keydown', function(e) {
const suggestions = suggestionsElement.querySelectorAll('.suggestion-item');
if (suggestions.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, suggestions.length - 1);
updateSelection(suggestions, selectedIndex);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
updateSelection(suggestions, selectedIndex);
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
// Trigger mousedown event instead of click
const mousedownEvent = new MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
view: window
});
suggestions[selectedIndex].dispatchEvent(mousedownEvent);
} else if (suggestions.length > 0) {
const mousedownEvent = new MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
view: window
});
suggestions[0].dispatchEvent(mousedownEvent);
}
} else if (e.key === 'Escape') {
hideSuggestions(suggestionsElement);
selectedIndex = -1;
} else if (e.key === 'Tab') {
if (suggestions.length > 0 && selectedIndex === -1) {
e.preventDefault();
const mousedownEvent = new MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
view: window
});
suggestions[0].dispatchEvent(mousedownEvent);
}
}
});
function updateSelection(suggestions, index) {
suggestions.forEach((suggestion, i) => {
suggestion.classList.remove('bg-blue-100', 'ring-2', 'ring-blue-500');
if (i === index) {
suggestion.classList.add('bg-blue-100', 'ring-2', 'ring-blue-500');
suggestion.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
}
}
async function searchAndShowSuggestions(query, inputElement, hiddenElement, suggestionsElement) {
try {
const response = await fetch(`/api/domains/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
if (!data.success) {
console.error('Erreur lors de la recherche de domaines:', data.error);
return;
}
suggestionsElement.innerHTML = '';
const domains = data.domains || [];
if (domains.length > 0) {
domains.forEach(domain => {
const suggestionDiv = createSuggestionItem(domain, query, inputElement, hiddenElement, suggestionsElement);
suggestionsElement.appendChild(suggestionDiv);
});
}
const exactMatch = domains.find(domain =>
domain.name.toLowerCase() === query.toLowerCase()
);
if (!exactMatch) {
const createDiv = document.createElement('div');
createDiv.className = 'suggestion-item px-3 py-2 text-sm cursor-pointer hover:bg-blue-50 border-t border-gray-200 transition-colors';
createDiv.innerHTML = `
<div class="flex items-center">
<svg class="w-4 h-4 text-blue-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd"/>
</svg>
<span class="text-blue-600 font-medium text-xs">Créer "${query}"</span>
</div>
`;
createDiv.addEventListener('mousedown', function(e) {
e.preventDefault();
currentDomainInput = inputElement;
showDomainCreationModal(query);
hideSuggestions(suggestionsElement);
});
suggestionsElement.appendChild(createDiv);
}
if (domains.length > 0 || !exactMatch) {
suggestionsElement.classList.remove('hidden');
} else {
suggestionsElement.classList.add('hidden');
}
} catch (error) {
console.error('Erreur lors de la recherche de domaines:', error);
suggestionsElement.classList.add('hidden');
}
}
function createSuggestionItem(domain, query, inputElement, hiddenElement, suggestionsElement) {
const div = document.createElement('div');
div.className = 'suggestion-item px-3 py-2 text-sm cursor-pointer hover:bg-gray-100 flex items-center transition-colors';
const colorIndicator = `<div class="w-3 h-3 rounded-full mr-2" style="background-color: ${domain.color}"></div>`;
const highlightedName = highlightMatch(domain.name, query);
div.innerHTML = `${colorIndicator}<span class="text-xs">${highlightedName}</span>`;
div.addEventListener('mousedown', function(e) {
e.preventDefault();
inputElement.value = domain.name;
hiddenElement.value = domain.id;
hideSuggestions(suggestionsElement);
inputElement.focus();
debouncedUpdateSummary();
});
return div;
}
function highlightMatch(text, query) {
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
if (lowerText.includes(lowerQuery)) {
const index = lowerText.indexOf(lowerQuery);
return text.substring(0, index) +
'<strong>' + text.substring(index, index + query.length) + '</strong>' +
text.substring(index + query.length);
}
return text;
}
function hideSuggestions(suggestionsElement) {
suggestionsElement.classList.add('hidden');
}
function showDomainCreationModal(suggestedName = '') {
const modal = document.getElementById('domain-creation-modal');
const nameInput = document.getElementById('new-domain-name');
const customColorInput = document.getElementById('custom-color');
if (nameInput) {
nameInput.value = suggestedName;
}
if (customColorInput) {
customColorInput.value = selectedColor;
}
updateColorSelection(selectedColor);
setupModalEvents();
modal.classList.remove('hidden');
if (nameInput) {
nameInput.focus();
}
}
function setupModalEvents() {
const confirmBtn = document.getElementById('confirm-domain-creation');
if (confirmBtn) {
confirmBtn.replaceWith(confirmBtn.cloneNode(true));
const newConfirmBtn = document.getElementById('confirm-domain-creation');
newConfirmBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
createNewDomain();
});
}
document.querySelectorAll('.close-modal').forEach(btn => {
btn.replaceWith(btn.cloneNode(true));
});
document.querySelectorAll('.close-modal').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
hideDomainCreationModal();
});
});
document.querySelectorAll('.color-option').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
updateColorSelection(this.dataset.color);
});
});
const customColorInput = document.getElementById('custom-color');
if (customColorInput) {
customColorInput.addEventListener('input', function() {
updateColorSelection(this.value);
});
}
}
function hideDomainCreationModal() {
const modal = document.getElementById('domain-creation-modal');
modal.classList.add('hidden');
currentDomainInput = null;
}
function updateColorSelection(color) {
selectedColor = color;
const customColorInput = document.getElementById('custom-color');
if (customColorInput) {
customColorInput.value = color;
}
document.querySelectorAll('.color-option').forEach(btn => {
btn.classList.remove('ring-2', 'ring-blue-500');
if (btn.dataset.color === color) {
btn.classList.add('ring-2', 'ring-blue-500');
}
});
}
async function createNewDomain() {
const nameInput = document.getElementById('new-domain-name');
const descriptionInput = document.getElementById('new-domain-description');
if (!nameInput) {
showToast('Erreur: champ nom non trouvé', 'error');
return;
}
const name = nameInput.value.trim();
const description = descriptionInput ? descriptionInput.value.trim() : '';
if (!name) {
showToast('Le nom du domaine est obligatoire', 'error');
return;
}
try {
const response = await fetch('/api/domains/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
color: selectedColor,
description: description
})
});
const data = await response.json();
if (data.success) {
availableDomains.push(data.domain);
if (currentDomainInput) {
currentDomainInput.value = data.domain.name;
const hiddenInput = currentDomainInput.parentElement.querySelector('.element-domain-id');
if (hiddenInput) {
hiddenInput.value = data.domain.id;
}
debouncedUpdateSummary();
}
hideDomainCreationModal();
showToast('Domaine créé avec succès !', 'success');
} else {
showToast(data.error || 'Erreur lors de la création du domaine', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showToast('Erreur de connexion', 'error');
}
}
// ============================================================================
// GESTION DES EXERCICES
// ============================================================================
function addExercise(existingData = null) {
exerciseCounter++;
const template = document.getElementById('exercise-template');
const exerciseDiv = template.content.cloneNode(true);
// Mettre à jour le numéro d'exercice
exerciseDiv.querySelector('.exercise-number').textContent = exerciseCounter;
exerciseDiv.querySelector('.exercise-order').value = exerciseCounter;
if (existingData) {
exerciseDiv.querySelector('.exercise-title').value = existingData.title;
exerciseDiv.querySelector('.exercise-description').value = existingData.description || '';
exerciseDiv.querySelector('.exercise-order').value = existingData.order;
} else {
// Pré-remplir avec "Exercice X" pour les nouveaux exercices
exerciseDiv.querySelector('.exercise-title').value = `Exercice ${exerciseCounter}`;
}
// Ajouter les event listeners
const exerciseElement = document.createElement('div');
exerciseElement.appendChild(exerciseDiv);
const actualExercise = exerciseElement.firstElementChild;
actualExercise.querySelector('.remove-exercise').addEventListener('click', function() {
removeExercise(this);
});
actualExercise.querySelector('.add-grading-element').addEventListener('click', function() {
addGradingElement(this);
});
actualExercise.querySelector('.exercise-title').addEventListener('input', () => {
debouncedUpdateSummary();
});
document.getElementById('exercises-container').appendChild(actualExercise);
// Charger les éléments existants si en mode édition
if (existingData && existingData.grading_elements) {
const elementsContainer = actualExercise.querySelector('.grading-elements-container');
existingData.grading_elements.forEach(elementData => {
addGradingElement(actualExercise.querySelector('.add-grading-element'), elementData);
});
actualExercise.querySelector('.no-grading-elements').style.display = 'none';
}
updateExercisesVisibility();
debouncedUpdateSummary();
// Focus sur le titre si nouvel exercice
if (!existingData) {
setTimeout(() => {
actualExercise.querySelector('.exercise-title').focus();
}, 100);
}
}
function removeExercise(btn) {
const exerciseItem = btn.closest('.exercise-item');
// Confirmation uniquement si l'exercice contient des éléments
const elementsCount = exerciseItem.querySelectorAll('.grading-element-item').length;
if (elementsCount > 0) {
if (!confirm(`Supprimer cet exercice et ses ${elementsCount} élément(s) de notation ?`)) {
return;
}
}
exerciseItem.remove();
updateExercisesVisibility();
renumberExercises();
debouncedUpdateSummary();
showToast('Exercice supprimé', 'info');
}
function updateExercisesVisibility() {
const container = document.getElementById('exercises-container');
const noExercisesMsg = document.getElementById('no-exercises');
const hasExercises = container.children.length > 0;
noExercisesMsg.style.display = hasExercises ? 'none' : 'block';
}
function renumberExercises() {
const exercises = document.querySelectorAll('.exercise-item');
exercises.forEach((exercise, index) => {
const number = index + 1;
exercise.querySelector('.exercise-number').textContent = number;
exercise.querySelector('.exercise-order').value = number;
});
exerciseCounter = exercises.length;
}
// ============================================================================
// GESTION DES ÉLÉMENTS DE NOTATION
// ============================================================================
function addGradingElement(btn, existingData = null) {
const exerciseDiv = btn.closest('.exercise-item');
const elementsContainer = exerciseDiv.querySelector('.grading-elements-container');
const noElementsMsg = exerciseDiv.querySelector('.no-grading-elements');
const template = document.getElementById('grading-element-template');
const elementDiv = template.content.cloneNode(true);
if (existingData) {
elementDiv.querySelector('.element-label').value = existingData.label;
elementDiv.querySelector('.element-skill').value = existingData.skill || '';
elementDiv.querySelector('.element-max-points').value = existingData.max_points;
elementDiv.querySelector('.element-grading-type').value = existingData.grading_type;
elementDiv.querySelector('.element-description').value = existingData.description || '';
// Remplir le domaine s'il existe
if (existingData.domain_id) {
const domainHiddenInput = elementDiv.querySelector('.element-domain-id');
const domainTextInput = elementDiv.querySelector('.element-domain-input');
if (domainHiddenInput && domainTextInput) {
domainHiddenInput.value = existingData.domain_id;
// Trouver le nom du domaine correspondant
const domain = availableDomains.find(d => d.id === existingData.domain_id);
if (domain) {
domainTextInput.value = domain.name;
}
}
}
}
const elementElement = document.createElement('div');
elementElement.appendChild(elementDiv);
const actualElement = elementElement.firstElementChild;
actualElement.querySelector('.remove-grading-element').addEventListener('click', function() {
removeGradingElement(this);
});
// Listeners pour mise à jour temps réel
actualElement.querySelectorAll('input, select').forEach(input => {
input.addEventListener('input', () => {
updateExerciseTotal(exerciseDiv);
debouncedUpdateSummary();
});
input.addEventListener('change', () => {
updateExerciseTotal(exerciseDiv);
debouncedUpdateSummary();
});
});
elementsContainer.appendChild(actualElement);
noElementsMsg.style.display = 'none';
// Configurer l'autocomplétion des domaines pour ce nouvel élément
setupDomainAutocomplete(actualElement);
updateExerciseTotal(exerciseDiv);
debouncedUpdateSummary();
if (!existingData) {
setTimeout(() => {
actualElement.querySelector('.element-label').focus();
}, 100);
}
}
function removeGradingElement(btn) {
const exerciseDiv = btn.closest('.exercise-item');
const elementsContainer = exerciseDiv.querySelector('.grading-elements-container');
const noElementsMsg = exerciseDiv.querySelector('.no-grading-elements');
btn.closest('.grading-element-item').remove();
if (elementsContainer.children.length === 0) {
noElementsMsg.style.display = 'block';
}
updateExerciseTotal(exerciseDiv);
debouncedUpdateSummary();
}
function updateExerciseTotal(exerciseDiv) {
const elements = exerciseDiv.querySelectorAll('.grading-element-item');
let total = 0;
elements.forEach(element => {
const pointsInput = element.querySelector('.element-max-points');
if (pointsInput && pointsInput.value) {
total += parseFloat(pointsInput.value) || 0;
}
});
const totalSpan = exerciseDiv.querySelector('.exercise-total');
if (totalSpan) {
totalSpan.textContent = `${total.toFixed(1)} pts`;
}
}
// ============================================================================
// SOUMISSION DU FORMULAIRE
// ============================================================================
function submitForm() {
const form = document.getElementById('unified-form');
const formData = new FormData(form);
const exercises = collectFormData();
// Validation côté client
if (exercises.length === 0) {
showToast('Vous devez ajouter au moins un exercice.', 'error');
return;
}
let hasElementsWithoutGrading = false;
exercises.forEach(ex => {
if (ex.grading_elements.length === 0) {
hasElementsWithoutGrading = true;
}
});
if (hasElementsWithoutGrading) {
showToast('Certains exercices n\'ont pas d\'éléments de notation.', 'warning');
if (!confirm('Certains exercices n\'ont pas d\'éléments de notation. Voulez-vous continuer ?')) {
return;
}
}
// Préparer les données
const data = {
title: formData.get('title'),
description: formData.get('description'),
date: formData.get('date'),
trimester: formData.get('trimester'),
class_group_id: formData.get('class_group_id'),
coefficient: formData.get('coefficient'),
exercises: exercises,
csrf_token: formData.get('csrf_token')
};
// Envoyer via AJAX
fetch(form.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('Évaluation créée avec succès !', 'success');
setTimeout(() => {
window.location.href = `/assessments/${data.assessment_id}`;
}, 500);
} else {
showToast('Erreur lors de la création : ' + (data.error || 'Erreur inconnue'), 'error');
}
})
.catch(error => {
console.error('Erreur:', error);
showToast('Une erreur est survenue lors de la création de l\'évaluation.', 'error');
});
}
// ============================================================================
// INITIALISATION
// ============================================================================
document.addEventListener('DOMContentLoaded', function() {
const addExerciseBtn = document.getElementById('add-exercise');
const form = document.getElementById('unified-form');
// Initialiser les domaines
loadDomains();
// Ajouter un exercice
addExerciseBtn.addEventListener('click', function() {
addExercise();
});
// Soumission du formulaire
form.addEventListener('submit', function(e) {
e.preventDefault();
submitForm();
});
// Fermer le modal avec Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
hideDomainCreationModal();
}
});
// Pré-remplir les données en mode édition
{% if is_edit %}
const existingExercises = {{ exercises_json|tojson if exercises_json else "[]" }};
existingExercises.forEach(exercise => {
addExercise(exercise);
});
{% endif %}
// Mettre à jour le résumé initial
setTimeout(updateSummaryBanner, 100);
});
</script>
<style>
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
#toast-container > div {
transition: opacity 0.3s, transform 0.3s;
}
</style>
{% endblock %}