mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-04 04:01:27 +00:00
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:
286
Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Normal file
286
Front-End/src/app/[locale]/admin/grades/[studentId]/page.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
import { disconnect } from '@/app/actions/authAction';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import Footer from '@/components/Footer';
|
||||
import MobileTopbar from '@/components/MobileTopbar';
|
||||
import { RIGHTS } from '@/utils/rights';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
|
||||
@ -123,9 +124,12 @@ export default function Layout({ children }) {
|
||||
|
||||
return (
|
||||
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
|
||||
{/* Topbar mobile (hamburger + logo) */}
|
||||
<MobileTopbar onMenuClick={toggleSidebar} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
className={`absolute top-14 md:top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
isSidebarOpen ? 'block' : 'hidden md:block'
|
||||
}`}
|
||||
>
|
||||
@ -146,7 +150,7 @@ export default function Layout({ children }) {
|
||||
)}
|
||||
|
||||
{/* Main container */}
|
||||
<div className="absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-64 right-0">
|
||||
<div className="absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-14 md:top-0 bottom-16 left-0 md:left-64 right-0">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
||||
@ -163,7 +163,7 @@ export default function DashboardPage() {
|
||||
if (isLoading) return <Loader />;
|
||||
|
||||
return (
|
||||
<div key={selectedEstablishmentId} className="p-6">
|
||||
<div key={selectedEstablishmentId} className="p-4 md:p-6">
|
||||
{/* Statistiques principales */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard
|
||||
@ -200,12 +200,12 @@ export default function DashboardPage() {
|
||||
{/* Colonne de gauche : Graphique des inscriptions + Présence */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Graphique des inscriptions */}
|
||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||
<h2 className="text-lg font-semibold mb-6">
|
||||
<div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||
<h2 className="text-lg font-semibold mb-4 md:mb-6">
|
||||
{t('inscriptionTrends')}
|
||||
</h2>
|
||||
<div className="flex flex-row gap-4">
|
||||
<div className="flex-1 p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-6 mt-4">
|
||||
<div className="flex-1">
|
||||
<LineChart data={monthlyRegistrations} />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
@ -214,13 +214,13 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Présence et assiduité */}
|
||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||
<div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1">
|
||||
<Attendance absences={absencesToday} readOnly={true} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colonne de droite : Événements à venir */}
|
||||
<div className="bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full">
|
||||
<div className="bg-stone-50 p-4 md:p-6 rounded-lg shadow-sm border border-gray-100 flex-1 h-full">
|
||||
<h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
|
||||
{upcomingEvents.map((event, index) => (
|
||||
<EventCard key={index} {...event} />
|
||||
|
||||
@ -12,6 +12,7 @@ import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
|
||||
export default function Page() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [eventData, setEventData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
@ -56,13 +57,17 @@ export default function Page() {
|
||||
modeSet={PlanningModes.PLANNING}
|
||||
>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<ScheduleNavigation />
|
||||
<ScheduleNavigation
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
/>
|
||||
<Calendar
|
||||
onDateClick={initializeNewEvent}
|
||||
onEventClick={(event) => {
|
||||
setEventData(event);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
onOpenDrawer={() => setIsDrawerOpen(true)}
|
||||
/>
|
||||
<EventModal
|
||||
isOpen={isModalOpen}
|
||||
|
||||
@ -71,6 +71,8 @@ export default function CreateSubscriptionPage() {
|
||||
const registerFormMoment = searchParams.get('school_year');
|
||||
|
||||
const [students, setStudents] = useState([]);
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
const [studentsPage, setStudentsPage] = useState(1);
|
||||
const [registrationDiscounts, setRegistrationDiscounts] = useState([]);
|
||||
const [tuitionDiscounts, setTuitionDiscounts] = useState([]);
|
||||
const [registrationFees, setRegistrationFees] = useState([]);
|
||||
@ -179,6 +181,8 @@ export default function CreateSubscriptionPage() {
|
||||
formDataRef.current = formData;
|
||||
}, [formData]);
|
||||
|
||||
useEffect(() => { setStudentsPage(1); }, [students]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData.guardianEmail) {
|
||||
// Si l'email est vide, réinitialiser existingProfileId et existingProfileInSchool
|
||||
@ -709,6 +713,9 @@ export default function CreateSubscriptionPage() {
|
||||
return finalAmount.toFixed(2);
|
||||
};
|
||||
|
||||
const studentsTotalPages = Math.ceil(students.length / ITEMS_PER_PAGE);
|
||||
const pagedStudents = students.slice((studentsPage - 1) * ITEMS_PER_PAGE, studentsPage * ITEMS_PER_PAGE);
|
||||
|
||||
if (isLoading === true) {
|
||||
return <Loader />; // Affichez le composant Loader
|
||||
}
|
||||
@ -869,7 +876,7 @@ export default function CreateSubscriptionPage() {
|
||||
{!isNewResponsable && (
|
||||
<div className="mt-4">
|
||||
<Table
|
||||
data={students}
|
||||
data={pagedStudents}
|
||||
columns={[
|
||||
{
|
||||
name: 'photo',
|
||||
@ -927,6 +934,10 @@ export default function CreateSubscriptionPage() {
|
||||
: ''
|
||||
}
|
||||
selectedRows={selectedStudent ? [selectedStudent.id] : []} // Assurez-vous que selectedRows est un tableau
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
currentPage={studentsPage}
|
||||
totalPages={studentsTotalPages}
|
||||
onPageChange={setStudentsPage}
|
||||
/>
|
||||
|
||||
{selectedStudent && (
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { MessageSquare, Settings, Home, Menu } from 'lucide-react';
|
||||
import { MessageSquare, Settings, Home } from 'lucide-react';
|
||||
import {
|
||||
FE_PARENTS_HOME_URL,
|
||||
FE_PARENTS_MESSAGERIE_URL
|
||||
@ -11,6 +11,7 @@ import {
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import { disconnect } from '@/app/actions/authAction';
|
||||
import Popup from '@/components/Popup';
|
||||
import MobileTopbar from '@/components/MobileTopbar';
|
||||
import { RIGHTS } from '@/utils/rights';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import Footer from '@/components/Footer';
|
||||
@ -73,17 +74,12 @@ export default function Layout({ children }) {
|
||||
|
||||
return (
|
||||
<ProtectedRoute requiredRight={RIGHTS.PARENT}>
|
||||
{/* Bouton hamburger pour mobile */}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="fixed top-4 left-4 z-40 p-2 rounded-md bg-white shadow-lg border border-gray-200 md:hidden"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
{/* Topbar mobile (hamburger + logo) */}
|
||||
<MobileTopbar onMenuClick={toggleSidebar} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`absolute top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
className={`absolute top-14 md:top-0 bottom-0 left-0 z-30 w-64 bg-white border-r border-gray-200 box-border ${
|
||||
isSidebarOpen ? 'block' : 'hidden md:block'
|
||||
}`}
|
||||
>
|
||||
@ -104,7 +100,7 @@ export default function Layout({ children }) {
|
||||
|
||||
{/* Main container */}
|
||||
<div
|
||||
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
|
||||
className={`absolute overflow-auto bg-gradient-to-br from-emerald-50 via-sky-50 to-emerald-100 top-14 md:top-0 bottom-16 left-0 md:left-64 right-0 ${!isMessagingPage ? 'p-4 md:p-8' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user