mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-05 12:41:27 +00:00
826 lines
31 KiB
JavaScript
826 lines
31 KiB
JavaScript
'use client';
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
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';
|
|
import {
|
|
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
|
|
FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL,
|
|
} from '@/utils/Url';
|
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
|
import {
|
|
fetchStudents,
|
|
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) {
|
|
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
|
|
const schoolYear = `${year}-${year + 1}`;
|
|
if (frequency === 1) return `T${periodValue}_${schoolYear}`;
|
|
if (frequency === 2) return `S${periodValue}_${schoolYear}`;
|
|
if (frequency === 3) return `A_${schoolYear}`;
|
|
return '';
|
|
}
|
|
|
|
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))
|
|
)
|
|
);
|
|
if (!scores.length) return null;
|
|
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) {
|
|
if (frequency === 1)
|
|
return [
|
|
{ label: 'Trimestre 1', value: 1 },
|
|
{ label: 'Trimestre 2', value: 2 },
|
|
{ label: 'Trimestre 3', value: 3 },
|
|
];
|
|
if (frequency === 2)
|
|
return [
|
|
{ label: 'Semestre 1', value: 1 },
|
|
{ label: 'Semestre 2', value: 2 },
|
|
];
|
|
if (frequency === 3) return [{ label: 'Année', value: 1 }];
|
|
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 =
|
|
{
|
|
1: [
|
|
{ value: 1, start: '09-01', end: '12-31' },
|
|
{ value: 2, start: '01-01', end: '03-31' },
|
|
{ value: 3, start: '04-01', end: '07-15' },
|
|
],
|
|
2: [
|
|
{ value: 1, start: '09-01', end: '01-31' },
|
|
{ value: 2, start: '02-01', end: '07-15' },
|
|
],
|
|
3: [{ value: 1, start: '09-01', end: '07-15' }],
|
|
}[frequency] || [];
|
|
const today = dayjs();
|
|
const current = periods.find(
|
|
(p) =>
|
|
today.isAfter(dayjs(`${today.year()}-${p.start}`).subtract(1, 'day')) &&
|
|
today.isBefore(dayjs(`${today.year()}-${p.end}`).add(1, 'day'))
|
|
);
|
|
return current?.value ?? null;
|
|
}
|
|
|
|
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 badgeColor =
|
|
color ||
|
|
(value >= 75
|
|
? 'bg-emerald-100 text-emerald-700'
|
|
: value >= 50
|
|
? 'bg-yellow-100 text-yellow-700'
|
|
: 'bg-red-100 text-red-600');
|
|
return (
|
|
<span
|
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${badgeColor}`}
|
|
>
|
|
{value}%
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default function Page() {
|
|
const router = useRouter();
|
|
const csrfToken = useCsrfToken();
|
|
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
|
useEstablishment();
|
|
const { getNiveauLabel } = useClasses();
|
|
const [students, setStudents] = useState([]);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const ITEMS_PER_PAGE = 15;
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
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
|
|
);
|
|
const currentPeriodValue = getCurrentPeriodValue(
|
|
selectedEstablishmentEvaluationFrequency
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!selectedEstablishmentId) return;
|
|
fetchStudents(selectedEstablishmentId, null, 5)
|
|
.then((data) => setStudents(data))
|
|
.catch((error) => logger.error('Error fetching students:', error));
|
|
|
|
fetchAbsences(selectedEstablishmentId)
|
|
.then((data) => {
|
|
const map = {};
|
|
(data || []).forEach((a) => {
|
|
if ([1, 2].includes(a.reason)) {
|
|
map[a.student] = (map[a.student] || 0) + 1;
|
|
}
|
|
});
|
|
setAbsencesMap(map);
|
|
})
|
|
.catch((error) => logger.error('Error fetching absences:', error));
|
|
}, [selectedEstablishmentId]);
|
|
|
|
// Fetch stats for all students - aggregate all periods
|
|
useEffect(() => {
|
|
if (!students.length || !selectedEstablishmentEvaluationFrequency) return;
|
|
|
|
setStatsLoading(true);
|
|
const frequency = selectedEstablishmentEvaluationFrequency;
|
|
|
|
const tasks = students.flatMap((student) =>
|
|
periodColumns.map(({ value: periodValue }) => {
|
|
const periodStr = getPeriodString(periodValue, frequency);
|
|
return fetchStudentCompetencies(student.id, periodStr)
|
|
.then((data) => ({ studentId: student.id, periodValue, data }))
|
|
.catch(() => ({ studentId: student.id, periodValue, data: null }));
|
|
})
|
|
);
|
|
|
|
Promise.all(tasks).then((results) => {
|
|
const map = {};
|
|
// 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)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
});
|
|
// 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);
|
|
});
|
|
}, [students, selectedEstablishmentEvaluationFrequency]);
|
|
|
|
const filteredStudents = students.filter(
|
|
(student) =>
|
|
!searchTerm ||
|
|
`${student.last_name} ${student.first_name}`
|
|
.toLowerCase()
|
|
.includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [searchTerm, students]);
|
|
|
|
const totalPages = Math.ceil(filteredStudents.length / ITEMS_PER_PAGE);
|
|
const pagedStudents = filteredStudents.slice(
|
|
(currentPage - 1) * ITEMS_PER_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(
|
|
currentPeriodValue,
|
|
selectedEstablishmentEvaluationFrequency
|
|
);
|
|
router.push(
|
|
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodStr}`
|
|
);
|
|
};
|
|
|
|
const columns = [
|
|
{ name: 'Photo', transform: () => null },
|
|
{ name: 'Élève', transform: () => null },
|
|
{ name: 'Niveau', transform: () => null },
|
|
{ name: 'Classe', transform: () => null },
|
|
...COMPETENCY_COLUMNS.map(({ label }) => ({
|
|
name: label,
|
|
transform: () => null,
|
|
})),
|
|
{ name: 'Absences', transform: () => null },
|
|
{ name: 'Actions', transform: () => null },
|
|
];
|
|
|
|
const renderCell = (student, column) => {
|
|
const stats = statsMap[student.id] || {};
|
|
switch (column) {
|
|
case 'Photo':
|
|
return (
|
|
<div className="flex justify-center items-center">
|
|
{student.photo ? (
|
|
<a
|
|
href={getSecureFileUrl(student.photo)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<img
|
|
src={getSecureFileUrl(student.photo)}
|
|
alt={`${student.first_name} ${student.last_name}`}
|
|
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
|
/>
|
|
</a>
|
|
) : (
|
|
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
|
|
<span className="text-gray-500 text-sm font-semibold">
|
|
{student.first_name?.[0]}
|
|
{student.last_name?.[0]}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
case 'Élève':
|
|
return (
|
|
<span className="font-semibold text-gray-700">
|
|
{student.last_name} {student.first_name}
|
|
</span>
|
|
);
|
|
case 'Niveau':
|
|
return getNiveauLabel(student.level);
|
|
case 'Classe':
|
|
return student.associated_class_id ? (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
router.push(
|
|
`${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${student.associated_class_id}`
|
|
);
|
|
}}
|
|
className="text-emerald-700 hover:underline font-medium"
|
|
>
|
|
{student.associated_class_name}
|
|
</button>
|
|
) : (
|
|
student.associated_class_name
|
|
);
|
|
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">
|
|
{absencesMap[student.id]}
|
|
</span>
|
|
) : (
|
|
<span className="text-gray-400 text-xs">0</span>
|
|
);
|
|
case 'Actions':
|
|
return (
|
|
<div className="flex items-center justify-center gap-2">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
router.push(`/admin/grades/${student.id}`);
|
|
}}
|
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-700 hover:bg-gray-200 transition whitespace-nowrap"
|
|
title="Voir la fiche"
|
|
>
|
|
<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}
|
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-emerald-100 text-emerald-700 hover:bg-emerald-200 transition whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed"
|
|
title="Évaluer"
|
|
>
|
|
<Award size={14} />
|
|
Évaluer
|
|
</button>
|
|
</div>
|
|
);
|
|
default: {
|
|
const col = COMPETENCY_COLUMNS.find((c) => c.label === column);
|
|
if (col) {
|
|
return (
|
|
<PercentBadge
|
|
value={stats?.[col.key] ?? null}
|
|
loading={statsLoading && !stats}
|
|
color={col.color}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="p-4 md:p-8 space-y-6">
|
|
<SectionHeader
|
|
icon={Award}
|
|
title="Suivi pédagogique"
|
|
description="Suivez le parcours d'un élève"
|
|
/>
|
|
<div className="relative flex-grow max-w-md">
|
|
<Search
|
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
|
|
size={20}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Rechercher un élève"
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<Table
|
|
data={pagedStudents}
|
|
columns={columns}
|
|
renderCell={renderCell}
|
|
itemsPerPage={ITEMS_PER_PAGE}
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
onPageChange={setCurrentPage}
|
|
emptyMessage={
|
|
<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>
|
|
);
|
|
}
|