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

@ -5,6 +5,7 @@ import SelectChoice from '@/components/Form/SelectChoice';
import Attendance from '@/components/Grades/Attendance';
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
import { EvaluationStudentView } from '@/components/Evaluation';
import Button from '@/components/Form/Button';
import logger from '@/utils/logger';
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, BASE_URL } from '@/utils/Url';
@ -15,9 +16,14 @@ import {
editAbsences,
deleteAbsences,
} from '@/app/actions/subscriptionAction';
import {
fetchEvaluations,
fetchStudentEvaluations,
updateStudentEvaluation,
} from '@/app/actions/schoolAction';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext';
import { Award, ArrowLeft } from 'lucide-react';
import { Award, ArrowLeft, BookOpen } from 'lucide-react';
import dayjs from 'dayjs';
import { useCsrfToken } from '@/context/CsrfContext';
@ -46,6 +52,10 @@ export default function StudentGradesPage() {
const [selectedPeriod, setSelectedPeriod] = useState(null);
const [allAbsences, setAllAbsences] = useState([]);
// Evaluation states
const [evaluations, setEvaluations] = useState([]);
const [studentEvaluationsData, setStudentEvaluationsData] = useState([]);
const getPeriods = () => {
if (selectedEstablishmentEvaluationFrequency === 1) {
return [
@ -135,6 +145,26 @@ export default function StudentGradesPage() {
}
}, [selectedEstablishmentId]);
// Load evaluations for the student
useEffect(() => {
if (student?.associated_class_id && selectedPeriod && selectedEstablishmentId) {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
// Load evaluations for the class
fetchEvaluations(selectedEstablishmentId, student.associated_class_id, periodString)
.then((data) => setEvaluations(data))
.catch((error) => logger.error('Erreur lors du fetch des évaluations:', error));
// Load student's evaluation scores
fetchStudentEvaluations(studentId, null, periodString, null)
.then((data) => setStudentEvaluationsData(data))
.catch((error) => logger.error('Erreur lors du fetch des notes:', error));
}
}, [student, selectedPeriod, selectedEstablishmentId]);
const absences = React.useMemo(() => {
return allAbsences
.filter((a) => a.student === studentId)
@ -176,6 +206,18 @@ export default function StudentGradesPage() {
);
};
const handleUpdateGrade = async (studentEvalId, data) => {
try {
await updateStudentEvaluation(studentEvalId, data, csrfToken);
// Reload student evaluations
const periodString = getPeriodString(selectedPeriod, selectedEstablishmentEvaluationFrequency);
const updatedData = await fetchStudentEvaluations(studentId, null, periodString, null);
setStudentEvaluationsData(updatedData);
} catch (error) {
logger.error('Erreur lors de la modification de la note:', error);
}
};
return (
<div className="p-4 md:p-8 space-y-6">
{/* Header */}
@ -280,6 +322,22 @@ export default function StudentGradesPage() {
<div>
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
</div>
{/* Évaluations par matière */}
<div className="bg-stone-50 rounded-lg shadow-sm border border-gray-200 p-4 md:p-6">
<div className="flex items-center gap-2 mb-4">
<BookOpen className="w-6 h-6 text-emerald-600" />
<h2 className="text-xl font-semibold text-gray-800">
Évaluations par matière
</h2>
</div>
<EvaluationStudentView
evaluations={evaluations}
studentEvaluations={studentEvaluationsData}
editable={true}
onUpdateGrade={handleUpdateGrade}
/>
</div>
</div>
</div>
);

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>
);
}

View File

@ -1,10 +1,10 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Users, Layers, CheckCircle, Clock, XCircle } from 'lucide-react';
import { Users, Layers, CheckCircle, Clock, XCircle, ClipboardList, Plus } from 'lucide-react';
import Table from '@/components/Table';
import Popup from '@/components/Popup';
import { fetchClasse } from '@/app/actions/schoolAction';
import { fetchClasse, fetchSpecialities, fetchEvaluations, createEvaluation, updateEvaluation, deleteEvaluation, fetchStudentEvaluations, saveStudentEvaluations, deleteStudentEvaluation } from '@/app/actions/schoolAction';
import { useSearchParams } from 'next/navigation';
import logger from '@/utils/logger';
import { useClasses } from '@/context/ClassesContext';
@ -17,10 +17,12 @@ import {
editAbsences,
deleteAbsences,
} from '@/app/actions/subscriptionAction';
import { EvaluationForm, EvaluationList, EvaluationGradeTable } from '@/components/Evaluation';
import { useCsrfToken } from '@/context/CsrfContext';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useNotification } from '@/context/NotificationContext';
import dayjs from 'dayjs';
export default function Page() {
const searchParams = useSearchParams();
@ -38,8 +40,53 @@ export default function Page() {
const [fetchedAbsences, setFetchedAbsences] = useState({}); // Absences récupérées depuis le backend
const [formAbsences, setFormAbsences] = useState({}); // Absences modifiées localement
// Tab system
const [activeTab, setActiveTab] = useState('attendance'); // 'attendance' ou 'evaluations'
// Evaluation states
const [specialities, setSpecialities] = useState([]);
const [evaluations, setEvaluations] = useState([]);
const [studentEvaluations, setStudentEvaluations] = useState([]);
const [showEvaluationForm, setShowEvaluationForm] = useState(false);
const [selectedEvaluation, setSelectedEvaluation] = useState(null);
const [selectedPeriod, setSelectedPeriod] = useState(null);
const [editingEvaluation, setEditingEvaluation] = useState(null);
const csrfToken = useCsrfToken();
const { selectedEstablishmentId } = useEstablishment();
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } = useEstablishment();
// Périodes selon la fréquence d'évaluation
const getPeriods = () => {
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
const nextYear = (year + 1).toString();
const schoolYear = `${year}-${nextYear}`;
if (selectedEstablishmentEvaluationFrequency === 1) {
return [
{ label: 'Trimestre 1', value: `T1_${schoolYear}` },
{ label: 'Trimestre 2', value: `T2_${schoolYear}` },
{ label: 'Trimestre 3', value: `T3_${schoolYear}` },
];
}
if (selectedEstablishmentEvaluationFrequency === 2) {
return [
{ label: 'Semestre 1', value: `S1_${schoolYear}` },
{ label: 'Semestre 2', value: `S2_${schoolYear}` },
];
}
if (selectedEstablishmentEvaluationFrequency === 3) {
return [{ label: 'Année', value: `A_${schoolYear}` }];
}
return [];
};
// Auto-select current period
useEffect(() => {
const periods = getPeriods();
if (periods.length > 0 && !selectedPeriod) {
setSelectedPeriod(periods[0].value);
}
}, [selectedEstablishmentEvaluationFrequency]);
// AbsenceMoment constants
const AbsenceMoment = {
@ -158,6 +205,87 @@ export default function Page() {
}
}, [filteredStudents, fetchedAbsences]);
// Load specialities for evaluations
useEffect(() => {
if (selectedEstablishmentId) {
fetchSpecialities(selectedEstablishmentId)
.then((data) => setSpecialities(data))
.catch((error) => logger.error('Erreur lors du chargement des matières:', error));
}
}, [selectedEstablishmentId]);
// Load evaluations when tab is active and period is selected
useEffect(() => {
if (activeTab === 'evaluations' && selectedEstablishmentId && schoolClassId && selectedPeriod) {
fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod)
.then((data) => setEvaluations(data))
.catch((error) => logger.error('Erreur lors du chargement des évaluations:', error));
}
}, [activeTab, selectedEstablishmentId, schoolClassId, selectedPeriod]);
// Load student evaluations when grading
useEffect(() => {
if (selectedEvaluation && schoolClassId) {
fetchStudentEvaluations(null, selectedEvaluation.id, null, schoolClassId)
.then((data) => setStudentEvaluations(data))
.catch((error) => logger.error('Erreur lors du chargement des notes:', error));
}
}, [selectedEvaluation, schoolClassId]);
// Handlers for evaluations
const handleCreateEvaluation = async (data) => {
try {
await createEvaluation(data, csrfToken);
showNotification('Évaluation créée avec succès', 'success', 'Succès');
setShowEvaluationForm(false);
// Reload evaluations
const updatedEvaluations = await fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod);
setEvaluations(updatedEvaluations);
} catch (error) {
logger.error('Erreur lors de la création:', error);
showNotification('Erreur lors de la création', 'error', 'Erreur');
}
};
const handleEditEvaluation = (evaluation) => {
setEditingEvaluation(evaluation);
setShowEvaluationForm(true);
};
const handleUpdateEvaluation = async (data) => {
try {
await updateEvaluation(editingEvaluation.id, data, csrfToken);
showNotification('Évaluation modifiée avec succès', 'success', 'Succès');
setShowEvaluationForm(false);
setEditingEvaluation(null);
// Reload evaluations
const updatedEvaluations = await fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod);
setEvaluations(updatedEvaluations);
} catch (error) {
logger.error('Erreur lors de la modification:', error);
showNotification('Erreur lors de la modification', 'error', 'Erreur');
}
};
const handleDeleteEvaluation = async (evaluationId) => {
await deleteEvaluation(evaluationId, csrfToken);
// Reload evaluations
const updatedEvaluations = await fetchEvaluations(selectedEstablishmentId, schoolClassId, selectedPeriod);
setEvaluations(updatedEvaluations);
};
const handleSaveGrades = async (gradesData) => {
await saveStudentEvaluations(gradesData, csrfToken);
// Reload student evaluations
const updatedStudentEvaluations = await fetchStudentEvaluations(null, selectedEvaluation.id, null, schoolClassId);
setStudentEvaluations(updatedStudentEvaluations);
};
const handleDeleteGrade = async (studentEvalId) => {
await deleteStudentEvaluation(studentEvalId, csrfToken);
setStudentEvaluations((prev) => prev.filter((se) => se.id !== studentEvalId));
};
const handleLevelClick = (label) => {
setSelectedLevels(
(prev) =>
@ -474,48 +602,83 @@ export default function Page() {
</div>
</div>
{/* Affichage de la date du jour */}
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-10 h-10 bg-emerald-100 text-emerald-600 rounded-full">
<Clock className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold text-gray-800">
Appel du jour :{' '}
<span className="ml-2 text-emerald-600">{today}</span>
</h2>
</div>
<div className="flex items-center">
{!isEditingAttendance ? (
<Button
text="Faire l'appel"
onClick={handleToggleAttendanceMode}
primary
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
/>
) : (
<Button
text="Valider l'appel"
onClick={handleValidateAttendance}
primary
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
/>
)}
{/* Tabs Navigation */}
<div className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="flex border-b border-gray-200">
<button
onClick={() => setActiveTab('attendance')}
className={`flex-1 py-3 px-4 text-center font-medium transition-colors ${
activeTab === 'attendance'
? 'text-emerald-600 border-b-2 border-emerald-600 bg-emerald-50'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-center gap-2">
<Clock className="w-5 h-5" />
Appel du jour
</div>
</button>
<button
onClick={() => setActiveTab('evaluations')}
className={`flex-1 py-3 px-4 text-center font-medium transition-colors ${
activeTab === 'evaluations'
? 'text-emerald-600 border-b-2 border-emerald-600 bg-emerald-50'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
<div className="flex items-center justify-center gap-2">
<ClipboardList className="w-5 h-5" />
Évaluations
</div>
</button>
</div>
</div>
<Table
columns={[
{
name: 'Nom',
transform: (row) => (
<div className="text-center">{row.last_name}</div>
),
},
{
name: 'Prénom',
transform: (row) => (
<div className="text-center">{row.first_name}</div>
{/* Tab Content: Attendance */}
{activeTab === 'attendance' && (
<>
{/* Affichage de la date du jour */}
<div className="flex justify-between items-center mb-4 bg-white p-4 rounded-lg shadow-md">
<div className="flex items-center space-x-3">
<div className="flex items-center justify-center w-10 h-10 bg-emerald-100 text-emerald-600 rounded-full">
<Clock className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold text-gray-800">
Appel du jour :{' '}
<span className="ml-2 text-emerald-600">{today}</span>
</h2>
</div>
<div className="flex items-center">
{!isEditingAttendance ? (
<Button
text="Faire l'appel"
onClick={handleToggleAttendanceMode}
primary
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
/>
) : (
<Button
text="Valider l'appel"
onClick={handleValidateAttendance}
primary
className="px-4 py-2 bg-emerald-500 text-white rounded-lg shadow hover:bg-emerald-600 transition-all"
/>
)}
</div>
</div>
<Table
columns={[
{
name: 'Nom',
transform: (row) => (
<div className="text-center">{row.last_name}</div>
),
},
{
name: 'Prénom',
transform: (row) => (
<div className="text-center">{row.first_name}</div>
),
},
{
@ -728,6 +891,84 @@ export default function Page() {
]}
data={filteredStudents} // Utiliser les élèves filtrés
/>
</>
)}
{/* Tab Content: Evaluations */}
{activeTab === 'evaluations' && (
<div className="space-y-4">
{/* Header avec sélecteur de période et bouton d'ajout */}
<div className="bg-white p-4 rounded-lg shadow-md">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<ClipboardList className="w-6 h-6 text-emerald-600" />
<h2 className="text-lg font-semibold text-gray-800">
Évaluations de la classe
</h2>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
<div className="w-48">
<SelectChoice
name="period"
placeHolder="Période"
choices={getPeriods()}
selected={selectedPeriod || ''}
callback={(e) => setSelectedPeriod(e.target.value)}
/>
</div>
<Button
primary
text="Nouvelle évaluation"
icon={<Plus size={16} />}
onClick={() => setShowEvaluationForm(true)}
/>
</div>
</div>
</div>
{/* Formulaire de création/édition d'évaluation */}
{showEvaluationForm && (
<EvaluationForm
specialities={specialities}
period={selectedPeriod}
schoolClassId={parseInt(schoolClassId)}
establishmentId={selectedEstablishmentId}
initialValues={editingEvaluation}
onSubmit={editingEvaluation ? handleUpdateEvaluation : handleCreateEvaluation}
onCancel={() => {
setShowEvaluationForm(false);
setEditingEvaluation(null);
}}
/>
)}
{/* Liste des évaluations */}
<div className="bg-white p-4 rounded-lg shadow-md">
<EvaluationList
evaluations={evaluations}
onDelete={handleDeleteEvaluation}
onEdit={handleEditEvaluation}
onGradeStudents={(evaluation) => setSelectedEvaluation(evaluation)}
/>
</div>
{/* Modal de notation */}
{selectedEvaluation && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="w-full max-w-4xl max-h-[90vh] overflow-auto">
<EvaluationGradeTable
evaluation={selectedEvaluation}
students={filteredStudents}
studentEvaluations={studentEvaluations}
onSave={handleSaveGrades}
onClose={() => setSelectedEvaluation(null)}
onDeleteGrade={handleDeleteGrade}
/>
</div>
</div>
)}
</div>
)}
{/* Popup */}
<Popup