mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-04 04:01:27 +00:00
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:
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user