Files
n3wt-school/Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Luc SORIGNET 4248a589c5 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
2026-03-16 12:27:06 +01:00

287 lines
9.9 KiB
JavaScript

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