feat(frontend): refonte mobile planning et ameliorations suivi pedagogique [#NEWTS-4]

Fonction PWA et ajout du responsive design

Planning mobile :
- Nouvelle vue DayView avec bandeau semaine scrollable, date picker natif et navigation integree
- ScheduleNavigation converti en drawer overlay sur mobile, sidebar fixe sur desktop
- Suppression double barre navigation mobile, controles deplaces dans DayView
- Date picker natif via label+input sur mobile

Suivi pedagogique :
- Refactorisation page grades avec composant Table partage
- Colonnes stats par periode, absences, actions (Fiche + Evaluer)
- Lien cliquable sur la classe vers SchoolClassManagement

feat(backend): ajout associated_class_id dans StudentByRFCreationSerializer [#NEWTS-4]

UI global :
- Remplacement fleches texte par icones Lucide ChevronDown/ChevronRight
- Pagination conditionnelle sur tous les tableaux plats
- Layout responsive mobile : cartes separees fond transparent
- Table.js : pagination optionnelle, wrapper md uniquement
This commit is contained in:
Luc SORIGNET
2026-03-16 12:25:37 +01:00
parent 7464b19de5
commit 4248a589c5
44 changed files with 1596 additions and 771 deletions

View File

@ -0,0 +1,286 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
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 Button from '@/components/Form/Button';
import logger from '@/utils/logger';
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, BASE_URL } from '@/utils/Url';
import {
fetchStudents,
fetchStudentCompetencies,
fetchAbsences,
editAbsences,
deleteAbsences,
} from '@/app/actions/subscriptionAction';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext';
import { Award, ArrowLeft } from 'lucide-react';
import dayjs from 'dayjs';
import { useCsrfToken } from '@/context/CsrfContext';
function getPeriodString(selectedPeriod, frequency) {
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
const nextYear = (year + 1).toString();
const schoolYear = `${year}-${nextYear}`;
if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`;
if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`;
if (frequency === 3) return `A_${schoolYear}`;
return '';
}
export default function StudentGradesPage() {
const router = useRouter();
const params = useParams();
const studentId = Number(params.studentId);
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
useEstablishment();
const { getNiveauLabel } = useClasses();
const [student, setStudent] = useState(null);
const [studentCompetencies, setStudentCompetencies] = useState(null);
const [grades, setGrades] = useState({});
const [selectedPeriod, setSelectedPeriod] = useState(null);
const [allAbsences, setAllAbsences] = useState([]);
const getPeriods = () => {
if (selectedEstablishmentEvaluationFrequency === 1) {
return [
{ label: 'Trimestre 1', value: 1, start: '09-01', end: '12-31' },
{ label: 'Trimestre 2', value: 2, start: '01-01', end: '03-31' },
{ label: 'Trimestre 3', value: 3, start: '04-01', end: '07-15' },
];
}
if (selectedEstablishmentEvaluationFrequency === 2) {
return [
{ label: 'Semestre 1', value: 1, start: '09-01', end: '01-31' },
{ label: 'Semestre 2', value: 2, start: '02-01', end: '07-15' },
];
}
if (selectedEstablishmentEvaluationFrequency === 3) {
return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }];
}
return [];
};
// Load student info
useEffect(() => {
if (selectedEstablishmentId) {
fetchStudents(selectedEstablishmentId, null, 5)
.then((students) => {
const found = students.find((s) => s.id === studentId);
setStudent(found || null);
})
.catch((error) => logger.error('Error fetching students:', error));
}
}, [selectedEstablishmentId, studentId]);
// Auto-select current period
useEffect(() => {
const periods = getPeriods();
const today = dayjs();
const current = periods.find((p) => {
const start = dayjs(`${today.year()}-${p.start}`);
const end = dayjs(`${today.year()}-${p.end}`);
return (
today.isAfter(start.subtract(1, 'day')) &&
today.isBefore(end.add(1, 'day'))
);
});
setSelectedPeriod(current ? current.value : null);
}, [selectedEstablishmentEvaluationFrequency]);
// Load competencies
useEffect(() => {
if (studentId && selectedPeriod) {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
fetchStudentCompetencies(studentId, periodString)
.then((data) => {
setStudentCompetencies(data);
if (data && data.data) {
const initialGrades = {};
data.data.forEach((domaine) => {
domaine.categories.forEach((cat) => {
cat.competences.forEach((comp) => {
initialGrades[comp.competence_id] = comp.score ?? 0;
});
});
});
setGrades(initialGrades);
}
})
.catch((error) =>
logger.error('Error fetching studentCompetencies:', error)
);
} else {
setGrades({});
setStudentCompetencies(null);
}
}, [studentId, selectedPeriod]);
// Load absences
useEffect(() => {
if (selectedEstablishmentId) {
fetchAbsences(selectedEstablishmentId)
.then((data) => setAllAbsences(data))
.catch((error) =>
logger.error('Erreur lors du fetch des absences:', error)
);
}
}, [selectedEstablishmentId]);
const absences = React.useMemo(() => {
return allAbsences
.filter((a) => a.student === studentId)
.map((a) => ({
id: a.id,
date: a.day,
type: [2, 1].includes(a.reason) ? 'Absence' : 'Retard',
reason: a.reason,
justified: [1, 3].includes(a.reason),
moment: a.moment,
commentaire: a.commentaire,
}));
}, [allAbsences, studentId]);
const handleToggleJustify = (absence) => {
const newReason =
absence.type === 'Absence'
? absence.justified ? 2 : 1
: absence.justified ? 4 : 3;
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
.then(() => {
setAllAbsences((prev) =>
prev.map((a) =>
a.id === absence.id ? { ...a, reason: newReason } : a
)
);
})
.catch((e) => logger.error('Erreur lors du changement de justification', e));
};
const handleDeleteAbsence = (absence) => {
return deleteAbsences(absence.id, csrfToken)
.then(() => {
setAllAbsences((prev) => prev.filter((a) => a.id !== absence.id));
})
.catch((e) =>
logger.error("Erreur lors de la suppression de l'absence", e)
);
};
return (
<div className="p-4 md:p-8 space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<button
onClick={() => router.push('/admin/grades')}
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
aria-label="Retour à la liste"
>
<ArrowLeft size={20} />
</button>
<h1 className="text-xl font-bold text-gray-800">Suivi pédagogique</h1>
</div>
{/* Student profile */}
{student && (
<div className="bg-stone-50 rounded-lg shadow-sm border border-gray-100 p-4 md:p-6 flex flex-col sm:flex-row items-center sm:items-start gap-4">
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`}
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow"
/>
) : (
<div className="w-24 h-24 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl border-4 border-emerald-100">
{student.first_name?.[0]}
{student.last_name?.[0]}
</div>
)}
<div className="flex-1 text-center sm:text-left">
<div className="text-xl font-bold text-emerald-800">
{student.last_name} {student.first_name}
</div>
<div className="text-sm text-gray-600 mt-1">
Niveau :{' '}
<span className="font-medium">
{getNiveauLabel(student.level)}
</span>
{' | '}
Classe :{' '}
<span className="font-medium">
{student.associated_class_name}
</span>
</div>
</div>
{/* Period selector + Evaluate button */}
<div className="flex flex-col sm:flex-row items-end gap-3 w-full sm:w-auto">
<div className="w-full sm:w-44">
<SelectChoice
name="period"
label="Période"
placeHolder="Choisir la période"
choices={getPeriods().map((period) => {
const today = dayjs();
const end = dayjs(`${today.year()}-${period.end}`);
return {
value: period.value,
label: period.label,
disabled: today.isAfter(end),
};
})}
selected={selectedPeriod || ''}
callback={(e) => setSelectedPeriod(Number(e.target.value))}
/>
</div>
<Button
primary
onClick={() => {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
router.push(
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodString}`
);
}}
className="px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full sm:w-auto"
icon={<Award className="w-5 h-5" />}
text="Évaluer"
title="Évaluer l'élève"
disabled={!selectedPeriod}
/>
</div>
</div>
)}
{/* Stats + Absences */}
<div className="flex flex-col gap-6">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<Attendance
absences={absences}
onToggleJustify={handleToggleJustify}
onDelete={handleDeleteAbsence}
/>
</div>
<div className="flex-1">
<GradesStatsCircle grades={grades} />
</div>
</div>
<div>
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
</div>
</div>
</div>
);
}

View File

@ -1,479 +1,351 @@
'use client';
import React, { useState, useEffect } from 'react';
import SelectChoice from '@/components/Form/SelectChoice';
import AcademicResults from '@/components/Grades/AcademicResults';
import Attendance from '@/components/Grades/Attendance';
import Remarks from '@/components/Grades/Remarks';
import WorkPlan from '@/components/Grades/WorkPlan';
import Homeworks from '@/components/Grades/Homeworks';
import SpecificEvaluations from '@/components/Grades/SpecificEvaluations';
import Orientation from '@/components/Grades/Orientation';
import GradesStatsCircle from '@/components/Grades/GradesStatsCircle';
import Button from '@/components/Form/Button';
import { useRouter } from 'next/navigation';
import { Award, Eye, Search } 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,
BASE_URL,
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL,
} from '@/utils/Url';
import { useRouter } from 'next/navigation';
import {
fetchStudents,
fetchStudentCompetencies,
searchStudents,
fetchAbsences,
editAbsences,
deleteAbsences,
} from '@/app/actions/subscriptionAction';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext';
import { Award, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
import InputText from '@/components/Form/InputText';
import dayjs from 'dayjs';
import { useCsrfToken } from '@/context/CsrfContext';
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 calcPercent(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))
)
);
if (!scores.length) return null;
return Math.round(
(scores.filter((s) => s === 3).length / scores.length) * 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 [];
}
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 }) {
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 =
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 ${color}`}
>
{value}%
</span>
);
}
export default function Page() {
const router = useRouter();
const csrfToken = useCsrfToken();
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
useEstablishment();
const { getNiveauLabel } = useClasses();
const [formData, setFormData] = useState({
selectedStudent: null,
});
const [students, setStudents] = useState([]);
const [studentCompetencies, setStudentCompetencies] = useState(null);
const [grades, setGrades] = useState({});
const [searchTerm, setSearchTerm] = useState('');
const [selectedPeriod, setSelectedPeriod] = useState(null);
const [allAbsences, setAllAbsences] = useState([]);
const ITEMS_PER_PAGE = 15;
const [currentPage, setCurrentPage] = useState(1);
const [statsMap, setStatsMap] = useState({});
const [statsLoading, setStatsLoading] = useState(false);
const [absencesMap, setAbsencesMap] = useState({});
// Définir les périodes selon la fréquence
const getPeriods = () => {
if (selectedEstablishmentEvaluationFrequency === 1) {
return [
{ label: 'Trimestre 1', value: 1, start: '09-01', end: '12-31' },
{ label: 'Trimestre 2', value: 2, start: '01-01', end: '03-31' },
{ label: 'Trimestre 3', value: 3, start: '04-01', end: '07-15' },
];
}
if (selectedEstablishmentEvaluationFrequency === 2) {
return [
{ label: 'Semestre 1', value: 1, start: '09-01', end: '01-31' },
{ label: 'Semestre 2', value: 2, start: '02-01', end: '07-15' },
];
}
if (selectedEstablishmentEvaluationFrequency === 3) {
return [{ label: 'Année', value: 1, start: '09-01', end: '07-15' }];
}
return [];
};
// Sélection automatique de la période courante
useEffect(() => {
if (!formData.selectedStudent) {
setSelectedPeriod(null);
return;
}
const periods = getPeriods();
const today = dayjs();
const current = periods.find((p) => {
const start = dayjs(`${today.year()}-${p.start}`);
const end = dayjs(`${today.year()}-${p.end}`);
return (
today.isAfter(start.subtract(1, 'day')) &&
today.isBefore(end.add(1, 'day'))
);
});
setSelectedPeriod(current ? current.value : null);
}, [formData.selectedStudent, selectedEstablishmentEvaluationFrequency]);
const academicResults = [
{
subject: 'Mathématiques',
grade: 16,
average: 14,
appreciation: 'Très bon travail',
},
{
subject: 'Français',
grade: 15,
average: 13,
appreciation: 'Bonne participation',
},
];
const remarks = [
{
date: '2023-09-10',
teacher: 'Mme Dupont',
comment: 'Participation active en classe.',
},
{
date: '2023-09-20',
teacher: 'M. Martin',
comment: 'Doit améliorer la concentration.',
},
];
const workPlan = [
{
objective: 'Renforcer la lecture',
support: 'Exercices hebdomadaires',
followUp: 'En cours',
},
{
objective: 'Maîtriser les tables de multiplication',
support: 'Jeux éducatifs',
followUp: 'À démarrer',
},
];
const homeworks = [
{ title: 'Rédaction', dueDate: '2023-10-10', status: 'Rendu' },
{ title: 'Exercices de maths', dueDate: '2023-10-12', status: 'À faire' },
];
const specificEvaluations = [
{
test: 'Bilan de compétences',
date: '2023-09-25',
result: 'Bon niveau général',
},
];
const orientation = [
{
date: '2023-10-01',
counselor: 'Mme Leroy',
advice: 'Poursuivre en filière générale',
},
];
const handleChange = (field, value) =>
setFormData((prev) => ({ ...prev, [field]: value }));
const periodColumns = getPeriodColumns(
selectedEstablishmentEvaluationFrequency
);
const currentPeriodValue = getCurrentPeriodValue(
selectedEstablishmentEvaluationFrequency
);
useEffect(() => {
if (selectedEstablishmentId) {
fetchStudents(selectedEstablishmentId, null, 5)
.then((studentsData) => {
setStudents(studentsData);
})
.catch((error) => logger.error('Error fetching students:', error));
}
}, [selectedEstablishmentId]);
if (!selectedEstablishmentId) return;
fetchStudents(selectedEstablishmentId, null, 5)
.then((data) => setStudents(data))
.catch((error) => logger.error('Error fetching students:', error));
// Charger les compétences et générer les grades à chaque changement d'élève sélectionné
useEffect(() => {
if (formData.selectedStudent && selectedPeriod) {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
fetchStudentCompetencies(formData.selectedStudent, periodString)
.then((data) => {
setStudentCompetencies(data);
// Générer les grades à partir du retour API
if (data && data.data) {
const initialGrades = {};
data.data.forEach((domaine) => {
domaine.categories.forEach((cat) => {
cat.competences.forEach((comp) => {
initialGrades[comp.competence_id] = comp.score ?? 0;
});
});
});
setGrades(initialGrades);
fetchAbsences(selectedEstablishmentId)
.then((data) => {
const map = {};
(data || []).forEach((a) => {
if ([1, 2].includes(a.reason)) {
map[a.student] = (map[a.student] || 0) + 1;
}
})
.catch((error) =>
logger.error('Error fetching studentCompetencies:', error)
);
} else {
setGrades({});
setStudentCompetencies(null);
}
}, [formData.selectedStudent, selectedPeriod]);
useEffect(() => {
if (selectedEstablishmentId) {
fetchAbsences(selectedEstablishmentId)
.then((data) => setAllAbsences(data))
.catch((error) =>
logger.error('Erreur lors du fetch des absences:', error)
);
}
});
setAbsencesMap(map);
})
.catch((error) => logger.error('Error fetching absences:', error));
}, [selectedEstablishmentId]);
// Transforme les absences backend pour l'élève sélectionné
const absences = React.useMemo(() => {
if (!formData.selectedStudent) return [];
return allAbsences
.filter((a) => a.student === formData.selectedStudent)
.map((a) => ({
id: a.id,
date: a.day,
type: [2, 1].includes(a.reason) ? 'Absence' : 'Retard',
reason: a.reason, // tu peux mapper le code vers un label si besoin
justified: [1, 3].includes(a.reason), // 1 et 3 = justifié
moment: a.moment,
commentaire: a.commentaire,
}));
}, [allAbsences, formData.selectedStudent]);
// Fetch stats for all students × all periods
useEffect(() => {
if (!students.length || !selectedEstablishmentEvaluationFrequency) return;
// Fonction utilitaire pour convertir la période sélectionnée en string backend
function getPeriodString(selectedPeriod, frequency) {
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; // année scolaire commence en septembre
const nextYear = (year + 1).toString();
const schoolYear = `${year}-${nextYear}`;
if (frequency === 1) return `T${selectedPeriod}_${schoolYear}`;
if (frequency === 2) return `S${selectedPeriod}_${schoolYear}`;
if (frequency === 3) return `A_${schoolYear}`;
return '';
}
setStatsLoading(true);
const frequency = selectedEstablishmentEvaluationFrequency;
// Callback pour justifier/non justifier une absence
const handleToggleJustify = (absence) => {
// Inverser l'état justifié (1/3 = justifié, 2/4 = non justifié)
const newReason =
absence.type === 'Absence'
? absence.justified
? 2 // Absence non justifiée
: 1 // Absence justifiée
: absence.justified
? 4 // Retard non justifié
: 3; // Retard justifié
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
.then(() => {
setAllAbsences((prev) =>
prev.map((a) =>
a.id === absence.id ? { ...a, reason: newReason } : a
)
);
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 }));
})
.catch((e) => {
logger.error('Erreur lors du changement de justification', e);
);
Promise.all(tasks).then((results) => {
const map = {};
results.forEach(({ studentId, periodValue, data }) => {
if (!map[studentId]) map[studentId] = {};
map[studentId][periodValue] = calcPercent(data);
});
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;
});
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 handleEvaluer = (e, studentId) => {
e.stopPropagation();
const periodStr = getPeriodString(
currentPeriodValue,
selectedEstablishmentEvaluationFrequency
);
router.push(
`${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${studentId}&period=${periodStr}`
);
};
// Callback pour supprimer une absence
const handleDeleteAbsence = (absence) => {
return deleteAbsences(absence.id, csrfToken)
.then(() => {
setAllAbsences((prev) => prev.filter((a) => a.id !== absence.id));
})
.catch((e) => {
logger.error("Erreur lors de la suppression de l'absence", e);
});
const columns = [
{ name: 'Photo', transform: () => null },
{ 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 },
{ 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={`${BASE_URL}${student.photo}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<img
src={`${BASE_URL}${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 '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">
{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) => 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 = periodColumns.find((c) => c.label === column);
if (col) {
return (
<PercentBadge
value={stats[col.value] ?? null}
loading={statsLoading && !(col.value in stats)}
/>
);
}
return null;
}
}
};
return (
<div className="p-8 space-y-8">
<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"
/>
{/* Section haute : filtre + bouton + photo élève */}
<div className="flex flex-row gap-8 items-start">
{/* Colonne gauche : InputText + bouton */}
<div className="w-4/5 flex items-end gap-4">
<div className="flex-[3_3_0%]">
<InputText
name="studentSearch"
type="text"
label="Recherche élève"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Rechercher un élève"
required={false}
enable={true}
/>
</div>
<div className="flex-[1_1_0%]">
<SelectChoice
name="period"
label="Période"
placeHolder="Choisir la période"
choices={getPeriods().map((period) => {
const today = dayjs();
const start = dayjs(`${today.year()}-${period.start}`);
const end = dayjs(`${today.year()}-${period.end}`);
const isPast = today.isAfter(end);
return {
value: period.value,
label: period.label,
disabled: isPast,
};
})}
selected={selectedPeriod || ''}
callback={(e) => setSelectedPeriod(Number(e.target.value))}
disabled={!formData.selectedStudent}
/>
</div>
<div className="flex-[1_1_0%] flex items-end">
<Button
primary
onClick={() => {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}&period=${periodString}`;
router.push(url);
}}
className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full"
icon={<Award className="w-6 h-6" />}
text="Evaluer"
title="Evaluez l'élève"
disabled={!formData.selectedStudent || !selectedPeriod}
/>
</div>
</div>
{/* Colonne droite : Photo élève */}
<div className="w-1/5 flex flex-col items-center justify-center">
{formData.selectedStudent &&
(() => {
const student = students.find(
(s) => s.id === formData.selectedStudent
);
if (!student) return null;
return (
<>
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`}
className="w-32 h-32 object-cover rounded-full border-4 border-emerald-200 mb-4 shadow"
/>
) : (
<div className="w-24 h-24 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl mb-4 border-4 border-emerald-100">
{student.first_name?.[0]}
{student.last_name?.[0]}
</div>
)}
</>
);
})()}
</div>
<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>
{/* Section basse : liste élèves + infos */}
<div className="flex flex-row gap-8 items-start mt-8">
{/* Colonne 1 : Liste des élèves */}
<div className="w-full max-w-xs">
<h3 className="text-lg font-semibold text-emerald-700 mb-4">
Liste des élèves
</h3>
<ul className="rounded-lg bg-stone-50 shadow border border-gray-100">
{students
.filter(
(student) =>
!searchTerm ||
`${student.last_name} ${student.first_name}`
.toLowerCase()
.includes(searchTerm.toLowerCase())
)
.map((student) => (
<li
key={student.id}
className={`flex items-center gap-4 px-4 py-3 hover:bg-emerald-100 cursor-pointer transition ${
formData.selectedStudent === student.id
? 'bg-emerald-100 border-l-4 border-emerald-400'
: 'border-l-2 border-gray-200'
}`}
onClick={() => handleChange('selectedStudent', student.id)}
>
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`}
className="w-10 h-10 object-cover rounded-full border-2 border-emerald-200"
/>
) : (
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-lg border-2 border-emerald-100">
{student.first_name?.[0]}
{student.last_name?.[0]}
</div>
)}
<div className="flex-1">
<div className="font-semibold text-emerald-800">
{student.last_name} {student.first_name}
</div>
<div className="text-xs text-gray-600">
Niveau :{' '}
<span className="font-medium">
{getNiveauLabel(student.level)}
</span>
{' | '}
Classe :{' '}
<span className="font-medium">
{student.associated_class_name}
</span>
</div>
</div>
{/* Icône PDF si bilan dispo pour la période sélectionnée */}
{selectedPeriod &&
student.bilans &&
Array.isArray(student.bilans) &&
(() => {
// Génère la string de période attendue
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
const bilan = student.bilans.find(
(b) => b.period === periodString && b.file
);
if (bilan) {
return (
<a
href={`${BASE_URL}${bilan.file}`}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-emerald-600 hover:text-emerald-800"
title="Télécharger le bilan de compétences"
onClick={(e) => e.stopPropagation()} // Pour ne pas sélectionner à nouveau l'élève
>
<FileText className="w-5 h-5" />
</a>
);
}
return null;
})()}
</li>
))}
</ul>
</div>
{/* Colonne 2 : Reste des infos */}
<div className="flex-1">
{formData.selectedStudent && (
<div className="flex flex-col gap-8 w-full justify-center items-stretch">
<div className="w-full flex flex-row items-stretch gap-4">
<div className="flex-1 flex items-stretch justify-center h-full">
<Attendance
absences={absences}
onToggleJustify={handleToggleJustify}
onDelete={handleDeleteAbsence}
/>
</div>
<div className="flex-1 flex items-stretch justify-center h-full">
<GradesStatsCircle grades={grades} />
</div>
</div>
<div className="flex items-center justify-center">
<GradesDomainBarChart
studentCompetencies={studentCompetencies}
/>
</div>
</div>
)}
</div>
</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>
}
/>
</div>
);
}

View File

@ -8,8 +8,8 @@ import {
fetchStudentCompetencies,
editStudentCompetencies,
} from '@/app/actions/subscriptionAction';
import SectionHeader from '@/components/SectionHeader';
import { Award } from 'lucide-react';
import { Award, ArrowLeft } from 'lucide-react';
import logger from '@/utils/logger';
import { useCsrfToken } from '@/context/CsrfContext';
import { useNotification } from '@/context/NotificationContext';
@ -70,7 +70,7 @@ export default function StudentCompetenciesPage() {
'success',
'Succès'
);
router.back();
router.push(`/admin/grades/${studentId}`);
})
.catch((error) => {
showNotification(
@ -83,11 +83,16 @@ export default function StudentCompetenciesPage() {
return (
<div className="h-full flex flex-col p-4">
<SectionHeader
icon={Award}
title="Bilan de compétence"
description="Evaluez les compétence de l'élève"
/>
<div className="flex items-center gap-3 mb-4">
<button
onClick={() => router.push('/admin/grades')}
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
aria-label="Retour à la fiche élève"
>
<ArrowLeft size={20} />
</button>
<h1 className="text-xl font-bold text-gray-800">Bilan de compétence</h1>
</div>
<div className="flex-1 min-h-0 flex flex-col">
<form
className="flex-1 min-h-0 flex flex-col"
@ -105,15 +110,6 @@ export default function StudentCompetenciesPage() {
/>
</div>
<div className="mt-6 flex justify-end">
<Button
text="Retour"
type="button"
onClick={(e) => {
e.preventDefault();
router.back();
}}
className="mr-2 bg-gray-200 text-gray-700 hover:bg-gray-300"
/>
<Button text="Enregistrer" primary type="submit" />
</div>
</form>