mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-04 04:01:27 +00:00
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
287 lines
9.9 KiB
JavaScript
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>
|
|
);
|
|
}
|