feat: Ajout d'un système de notation par classe et par matière et par élève [N3WTS-6]

This commit is contained in:
N3WT DE COMPET
2026-04-03 22:10:32 +02:00
parent edb9ace6ae
commit 905fa5dbfb
15 changed files with 1970 additions and 79 deletions

View File

@ -1,7 +1,7 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Award, Eye, Search } from 'lucide-react';
import { Award, Eye, Search, BarChart2, X, Pencil, Trash2, Save } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
import Table from '@/components/Table';
import logger from '@/utils/logger';
@ -15,8 +15,10 @@ import {
fetchStudentCompetencies,
fetchAbsences,
} from '@/app/actions/subscriptionAction';
import { fetchStudentEvaluations, updateStudentEvaluation, deleteStudentEvaluation } from '@/app/actions/schoolAction';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext';
import { useCsrfToken } from '@/context/CsrfContext';
import dayjs from 'dayjs';
function getPeriodString(periodValue, frequency) {
@ -28,18 +30,22 @@ function getPeriodString(periodValue, frequency) {
return '';
}
function calcPercent(data) {
function calcCompetencyStats(data) {
if (!data?.data) return null;
const scores = [];
data.data.forEach((d) =>
d.categories.forEach((c) =>
c.competences.forEach((comp) => scores.push(comp.score ?? 0))
c.competences.forEach((comp) => scores.push(comp.score))
)
);
if (!scores.length) return null;
return Math.round(
(scores.filter((s) => s === 3).length / scores.length) * 100
);
const total = scores.length;
return {
acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100),
inProgress: Math.round((scores.filter((s) => s === 2).length / total) * 100),
notAcquired: Math.round((scores.filter((s) => s === 1).length / total) * 100),
notEvaluated: Math.round((scores.filter((s) => s === null || s === undefined || s === 0).length / total) * 100),
};
}
function getPeriodColumns(frequency) {
@ -58,6 +64,13 @@ function getPeriodColumns(frequency) {
return [];
}
const COMPETENCY_COLUMNS = [
{ key: 'acquired', label: 'Acquises', color: 'bg-emerald-100 text-emerald-700' },
{ key: 'inProgress', label: 'En cours', color: 'bg-yellow-100 text-yellow-700' },
{ key: 'notAcquired', label: 'Non acquises', color: 'bg-red-100 text-red-600' },
{ key: 'notEvaluated', label: 'Non évaluées', color: 'bg-gray-100 text-gray-600' },
];
function getCurrentPeriodValue(frequency) {
const periods =
{
@ -81,18 +94,19 @@ function getCurrentPeriodValue(frequency) {
return current?.value ?? null;
}
function PercentBadge({ value, loading }) {
function PercentBadge({ value, loading, color }) {
if (loading) return <span className="text-gray-300 text-xs"></span>;
if (value === null) return <span className="text-gray-400 text-xs"></span>;
const color =
const badgeColor = color || (
value >= 75
? 'bg-emerald-100 text-emerald-700'
: value >= 50
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-600';
: 'bg-red-100 text-red-600'
);
return (
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${color}`}
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${badgeColor}`}
>
{value}%
</span>
@ -101,6 +115,7 @@ function PercentBadge({ value, loading }) {
export default function Page() {
const router = useRouter();
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
useEstablishment();
const { getNiveauLabel } = useClasses();
@ -111,6 +126,12 @@ export default function Page() {
const [statsMap, setStatsMap] = useState({});
const [statsLoading, setStatsLoading] = useState(false);
const [absencesMap, setAbsencesMap] = useState({});
const [gradesModalStudent, setGradesModalStudent] = useState(null);
const [studentEvaluations, setStudentEvaluations] = useState([]);
const [gradesLoading, setGradesLoading] = useState(false);
const [editingEvalId, setEditingEvalId] = useState(null);
const [editScore, setEditScore] = useState('');
const [editAbsent, setEditAbsent] = useState(false);
const periodColumns = getPeriodColumns(
selectedEstablishmentEvaluationFrequency
@ -138,7 +159,7 @@ export default function Page() {
.catch((error) => logger.error('Error fetching absences:', error));
}, [selectedEstablishmentId]);
// Fetch stats for all students × all periods
// Fetch stats for all students - aggregate all periods
useEffect(() => {
if (!students.length || !selectedEstablishmentEvaluationFrequency) return;
@ -156,15 +177,32 @@ export default function Page() {
Promise.all(tasks).then((results) => {
const map = {};
results.forEach(({ studentId, periodValue, data }) => {
if (!map[studentId]) map[studentId] = {};
map[studentId][periodValue] = calcPercent(data);
// Group by student and aggregate all competency scores across periods
const studentScores = {};
results.forEach(({ studentId, data }) => {
if (!studentScores[studentId]) studentScores[studentId] = [];
if (data?.data) {
data.data.forEach((d) =>
d.categories.forEach((c) =>
c.competences.forEach((comp) => studentScores[studentId].push(comp.score))
)
);
}
});
Object.keys(map).forEach((id) => {
const vals = Object.values(map[id]).filter((v) => v !== null);
map[id].global = vals.length
? Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)
: null;
// Calculate stats for each student
Object.keys(studentScores).forEach((studentId) => {
const scores = studentScores[studentId];
if (!scores.length) {
map[studentId] = null;
} else {
const total = scores.length;
map[studentId] = {
acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100),
inProgress: Math.round((scores.filter((s) => s === 2).length / total) * 100),
notAcquired: Math.round((scores.filter((s) => s === 1).length / total) * 100),
notEvaluated: Math.round((scores.filter((s) => s === null || s === undefined || s === 0).length / total) * 100),
};
}
});
setStatsMap(map);
setStatsLoading(false);
@ -189,6 +227,81 @@ export default function Page() {
currentPage * ITEMS_PER_PAGE
);
const openGradesModal = (e, student) => {
e.stopPropagation();
setGradesModalStudent(student);
setGradesLoading(true);
fetchStudentEvaluations(student.id)
.then((data) => {
setStudentEvaluations(data || []);
setGradesLoading(false);
})
.catch((error) => {
logger.error('Error fetching student evaluations:', error);
setStudentEvaluations([]);
setGradesLoading(false);
});
};
const closeGradesModal = () => {
setGradesModalStudent(null);
setStudentEvaluations([]);
setEditingEvalId(null);
};
const startEditingEval = (evalItem) => {
setEditingEvalId(evalItem.id);
setEditScore(evalItem.score ?? '');
setEditAbsent(evalItem.is_absent ?? false);
};
const cancelEditingEval = () => {
setEditingEvalId(null);
setEditScore('');
setEditAbsent(false);
};
const handleSaveEval = async (evalItem) => {
try {
await updateStudentEvaluation(evalItem.id, {
score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)),
is_absent: editAbsent,
}, csrfToken);
// Update local state
setStudentEvaluations((prev) =>
prev.map((e) =>
e.id === evalItem.id
? { ...e, score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)), is_absent: editAbsent }
: e
)
);
cancelEditingEval();
} catch (error) {
logger.error('Error updating evaluation:', error);
}
};
const handleDeleteEval = async (evalItem) => {
if (!confirm('Supprimer cette note ?')) return;
try {
await deleteStudentEvaluation(evalItem.id, csrfToken);
setStudentEvaluations((prev) => prev.filter((e) => e.id !== evalItem.id));
} catch (error) {
logger.error('Error deleting evaluation:', error);
}
};
// Group evaluations by subject
const groupedBySubject = studentEvaluations.reduce((acc, evalItem) => {
const subjectName = evalItem.speciality_name || 'Sans matière';
const subjectColor = evalItem.speciality_color || '#6B7280';
if (!acc[subjectName]) {
acc[subjectName] = { color: subjectColor, evaluations: [] };
}
acc[subjectName].evaluations.push(evalItem);
return acc;
}, {});
const handleEvaluer = (e, studentId) => {
e.stopPropagation();
const periodStr = getPeriodString(
@ -205,8 +318,7 @@ export default function Page() {
{ name: 'Élève', transform: () => null },
{ name: 'Niveau', transform: () => null },
{ name: 'Classe', transform: () => null },
...periodColumns.map(({ label }) => ({ name: label, transform: () => null })),
{ name: 'Stat globale', transform: () => null },
...COMPETENCY_COLUMNS.map(({ label }) => ({ name: label, transform: () => null })),
{ name: 'Absences', transform: () => null },
{ name: 'Actions', transform: () => null },
];
@ -261,13 +373,6 @@ export default function Page() {
) : (
student.associated_class_name
);
case 'Stat globale':
return (
<PercentBadge
value={stats.global ?? null}
loading={statsLoading && !('global' in stats)}
/>
);
case 'Absences':
return absencesMap[student.id] ? (
<span className="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-600">
@ -287,6 +392,14 @@ export default function Page() {
<Eye size={14} />
Fiche
</button>
<button
onClick={(e) => openGradesModal(e, student)}
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-700 hover:bg-blue-200 transition whitespace-nowrap"
title="Voir les notes"
>
<BarChart2 size={14} />
Notes
</button>
<button
onClick={(e) => handleEvaluer(e, student.id)}
disabled={!currentPeriodValue}
@ -299,12 +412,13 @@ export default function Page() {
</div>
);
default: {
const col = periodColumns.find((c) => c.label === column);
const col = COMPETENCY_COLUMNS.find((c) => c.label === column);
if (col) {
return (
<PercentBadge
value={stats[col.value] ?? null}
loading={statsLoading && !(col.value in stats)}
value={stats?.[col.key] ?? null}
loading={statsLoading && !stats}
color={col.color}
/>
);
}
@ -346,6 +460,233 @@ export default function Page() {
<span className="text-gray-400 text-sm">Aucun élève trouvé</span>
}
/>
{/* Modal Notes par matière */}
{gradesModalStudent && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-gray-50">
<div>
<h2 className="text-lg font-semibold text-gray-800">
Notes de {gradesModalStudent.first_name} {gradesModalStudent.last_name}
</h2>
<p className="text-sm text-gray-500">
{gradesModalStudent.associated_class_name || 'Classe non assignée'}
</p>
</div>
<button
onClick={closeGradesModal}
className="p-2 hover:bg-gray-200 rounded-full transition"
>
<X size={20} className="text-gray-500" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{gradesLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-emerald-600"></div>
</div>
) : Object.keys(groupedBySubject).length === 0 ? (
<div className="text-center py-12 text-gray-400">
Aucune note enregistrée pour cet élève.
</div>
) : (
<div className="space-y-6">
{/* Résumé des moyennes */}
{(() => {
const subjectAverages = Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => {
const scores = evaluations
.filter(e => e.score !== null && e.score !== undefined && !e.is_absent)
.map(e => parseFloat(e.score))
.filter(s => !isNaN(s));
const avg = scores.length
? scores.reduce((sum, s) => sum + s, 0) / scores.length
: null;
return { subject, color, avg };
}).filter(s => s.avg !== null && !isNaN(s.avg));
const overallAvg = subjectAverages.length
? (subjectAverages.reduce((sum, s) => sum + s.avg, 0) / subjectAverages.length).toFixed(1)
: null;
return (
<div className="bg-gradient-to-r from-emerald-50 to-blue-50 rounded-lg p-4 border border-emerald-100">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-600">Résumé</span>
{overallAvg !== null && (
<span className="text-lg font-bold text-emerald-700">
Moyenne générale : {overallAvg}/20
</span>
)}
</div>
<div className="flex flex-wrap gap-3">
{subjectAverages.map(({ subject, color, avg }) => (
<div
key={subject}
className="flex items-center gap-2 bg-white px-3 py-1.5 rounded-full border shadow-sm"
>
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: color }}
></span>
<span className="text-sm text-gray-700">{subject}</span>
<span className="text-sm font-semibold text-gray-800">
{avg.toFixed(1)}
</span>
</div>
))}
</div>
</div>
);
})()}
{Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => {
const scores = evaluations
.filter(e => e.score !== null && e.score !== undefined && !e.is_absent)
.map(e => parseFloat(e.score))
.filter(s => !isNaN(s));
const avg = scores.length
? (scores.reduce((sum, s) => sum + s, 0) / scores.length).toFixed(1)
: null;
return (
<div key={subject} className="border rounded-lg overflow-hidden">
<div
className="flex items-center justify-between px-4 py-3"
style={{ backgroundColor: `${color}20` }}
>
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: color }}
></span>
<span className="font-semibold text-gray-800">{subject}</span>
</div>
{avg !== null && (
<span className="text-sm font-bold text-gray-700">
Moyenne : {avg}
</span>
)}
</div>
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="text-left px-4 py-2 font-medium text-gray-600">Évaluation</th>
<th className="text-left px-4 py-2 font-medium text-gray-600">Période</th>
<th className="text-right px-4 py-2 font-medium text-gray-600">Note</th>
<th className="text-center px-4 py-2 font-medium text-gray-600 w-24">Actions</th>
</tr>
</thead>
<tbody>
{evaluations.map((evalItem) => {
const isEditing = editingEvalId === evalItem.id;
return (
<tr key={evalItem.id} className="border-t hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">
{evalItem.evaluation_name || 'Évaluation'}
</td>
<td className="px-4 py-2 text-gray-500">
{evalItem.period || '—'}
</td>
<td className="px-4 py-2 text-right">
{isEditing ? (
<div className="flex items-center justify-end gap-2">
<label className="flex items-center gap-1 text-xs text-gray-600">
<input
type="checkbox"
checked={editAbsent}
onChange={(e) => {
setEditAbsent(e.target.checked);
if (e.target.checked) setEditScore('');
}}
/>
Abs
</label>
{!editAbsent && (
<input
type="number"
value={editScore}
onChange={(e) => setEditScore(e.target.value)}
min="0"
max={evalItem.max_score || 20}
step="0.5"
className="w-16 text-center px-1 py-0.5 border rounded text-sm"
/>
)}
<span className="text-gray-500">/{evalItem.max_score || 20}</span>
</div>
) : evalItem.is_absent ? (
<span className="text-orange-500 font-medium">Absent</span>
) : evalItem.score !== null ? (
<span className="font-semibold text-gray-800">
{evalItem.score}/{evalItem.max_score || 20}
</span>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-4 py-2 text-center">
{isEditing ? (
<div className="flex items-center justify-center gap-1">
<button
onClick={() => handleSaveEval(evalItem)}
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
title="Enregistrer"
>
<Save size={14} />
</button>
<button
onClick={cancelEditingEval}
className="p-1 text-gray-500 hover:bg-gray-100 rounded"
title="Annuler"
>
<X size={14} />
</button>
</div>
) : (
<div className="flex items-center justify-center gap-1">
<button
onClick={() => startEditingEval(evalItem)}
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
title="Modifier"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDeleteEval(evalItem)}
className="p-1 text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
<Trash2 size={14} />
</button>
</div>
)}
</td>
</tr>
);})}
</tbody>
</table>
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t bg-gray-50 flex justify-end">
<button
onClick={closeGradesModal}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition"
>
Fermer
</button>
</div>
</div>
</div>
)}
</div>
);
}