feat: Rattachement d'un dossier de compétences à une période scolaire

(configuration dans l'établissement) [#16]
This commit is contained in:
N3WT DE COMPET
2025-05-22 01:25:34 +02:00
parent 0fe6c76189
commit 7de839ee5c
18 changed files with 450 additions and 274 deletions

View File

@ -23,14 +23,16 @@ import {
} from '@/app/actions/subscriptionAction';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext';
import StudentInput from '@/components/Grades/StudentInput';
import { Award, BookOpen } from 'lucide-react';
import { Award, BookOpen, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
import InputText from '@/components/InputText';
import dayjs from 'dayjs';
export default function Page() {
const router = useRouter();
const { selectedEstablishmentId } = useEstablishment();
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
useEstablishment();
const { getNiveauLabel } = useClasses();
const [formData, setFormData] = useState({
selectedStudent: null,
@ -39,6 +41,48 @@ export default function Page() {
const [students, setStudents] = useState([]);
const [studentCompetencies, setStudentCompetencies] = useState(null);
const [grades, setGrades] = useState({});
const [searchTerm, setSearchTerm] = useState('');
const [selectedPeriod, setSelectedPeriod] = useState(null);
// 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 = [
{
@ -122,8 +166,12 @@ export default function Page() {
// Charger les compétences et générer les grades à chaque changement d'élève sélectionné
useEffect(() => {
if (formData.selectedStudent) {
fetchStudentCompetencies(formData.selectedStudent)
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
@ -146,105 +194,218 @@ export default function Page() {
setGrades({});
setStudentCompetencies(null);
}
}, [formData.selectedStudent]);
}, [formData.selectedStudent, selectedPeriod]);
// 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 '';
}
return (
<div className="p-8 space-y-8">
{/* Sélection de l'élève */}
<SectionHeader
icon={BookOpen}
title="Suivi pédagogique"
description="Suivez le parcours d'un élève"
/>
<div className="flex flex-col md:flex-row md:items-start gap-4">
{/* Recherche élève + bouton + fiche élève */}
<div className="flex-1 flex flex-row gap-4 items-start">
{/* 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-1">
<StudentInput
<InputText
name="studentSearch"
type="text"
label="Recherche élève"
selectedStudent={
students.find((s) => s.id === formData.selectedStudent) || null
}
setSelectedStudent={(student) =>
handleChange('selectedStudent', student?.id || '')
}
searchStudents={searchStudents}
establishmentId={selectedEstablishmentId}
required
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Rechercher un élève"
required={false}
enable={true}
/>
</div>
{/* Sélecteur de période */}
{formData.selectedStudent && (
<div className="ml-4 flex flex-col items-center min-w-[220px] max-w-xs p-4 rounded-lg border border-emerald-100 shadow">
{(() => {
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-20 h-20 object-cover rounded-full border-4 border-emerald-200 mb-2 shadow"
/>
) : (
<div className="w-20 h-20 flex items-center justify-center bg-gray-200 rounded-full text-gray-500 font-bold text-3xl mb-2 border-4 border-emerald-100">
{student.first_name?.[0]}
{student.last_name?.[0]}
</div>
)}
<div className="text-center">
<div className="text-base font-semibold text-emerald-800">
{student.last_name} {student.first_name}
</div>
<div className="text-xs text-gray-600 mt-1">
Niveau :{' '}
<span className="font-medium">
{getNiveauLabel(student.level)}
</span>
</div>
<div className="text-xs text-gray-600">
Classe :{' '}
<span className="font-medium">
{student.associated_class_name}
</span>
</div>
</div>
{/* Bouton bilan de compétences en dessous */}
<Button
primary
onClick={() => {
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}`;
router.push(`${url}`);
}}
className="mt-4 px-6 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600"
icon={<Award className="w-6 h-6" />}
title="Réaliser le bilan de compétences"
<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={false}
/>
)}
<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"
icon={<Award className="w-6 h-6" />}
text="Evaluer"
title="Evaluez l'élève"
disabled={!formData.selectedStudent || !selectedPeriod}
/>
</div>
{/* Colonne droite : Photo élève */}
<div className="w-2/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>
{/* 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} />
</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>
{/* Partie basse : stats à gauche, présence à droite, sans bg */}
{formData.selectedStudent && (
<div className="flex flex-col gap-8 w-full justify-center items-stretch mt-8">
<div className="w-3/4 flex flex-row items-stretch gap-4 mx-auto">
<div className="flex-1 flex items-center justify-center">
<Attendance absences={absences} />
</div>
<div className="flex-1 flex items-center justify-center">
<GradesStatsCircle grades={grades} />
</div>
</div>
<div className="flex items-center justify-center">
<GradesDomainBarChart studentCompetencies={studentCompetencies} />
</div>
</div>
)}
</div>
);
}

View File

@ -21,9 +21,10 @@ export default function StudentCompetenciesPage() {
const [studentCompetencies, setStudentCompetencies] = useState([]);
const [grades, setGrades] = useState({});
const studentId = searchParams.get('studentId');
const period = searchParams.get('period');
useEffect(() => {
fetchStudentCompetencies(studentId)
fetchStudentCompetencies(studentId, period)
.then((data) => {
setStudentCompetencies(data);
})
@ -64,6 +65,7 @@ export default function StudentCompetenciesPage() {
studentId,
competenceId,
grade: score,
period: period,
}));
editStudentCompetencies(data, csrfToken)
.then(() => {

View File

@ -42,7 +42,7 @@ import {
import { fetchProfiles } from '@/app/actions/authAction';
import { useClasses } from '@/context/ClassesContext';
import { useCsrfToken } from '@/context/CsrfContext';
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
import { FE_ADMIN_SUBSCRIPTIONS_URL, BASE_URL } from '@/utils/Url';
export default function CreateSubscriptionPage() {
const [formData, setFormData] = useState({

View File

@ -24,16 +24,18 @@ export const editStudentCompetencies = (data, csrfToken) => {
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};
export const fetchStudentCompetencies = (id) => {
const request = new Request(
`${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
export const fetchStudentCompetencies = (id, period) => {
// Si period est vide, ne pas l'ajouter à l'URL
const url = period
? `${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}&period=${period}`
: `${BE_SUBSCRIPTION_STUDENT_COMPETENCIES_URL}?student_id=${id}`;
const request = new Request(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
return fetch(request).then(requestResponseHandler).catch(errorHandler);
};