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

@ -452,11 +452,12 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False) id = serializers.IntegerField(required=False)
guardians = GuardianByDICreationSerializer(many=True, required=False) guardians = GuardianByDICreationSerializer(many=True, required=False)
associated_class_name = serializers.SerializerMethodField() associated_class_name = serializers.SerializerMethodField()
associated_class_id = serializers.SerializerMethodField()
bilans = BilanCompetenceSerializer(many=True, read_only=True) bilans = BilanCompetenceSerializer(many=True, read_only=True)
class Meta: class Meta:
model = Student model = Student
fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'photo', 'bilans'] fields = ['id', 'last_name', 'first_name', 'guardians', 'level', 'associated_class_name', 'associated_class_id', 'photo', 'bilans']
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs) super(StudentByRFCreationSerializer, self).__init__(*args, **kwargs)
@ -466,6 +467,9 @@ class StudentByRFCreationSerializer(serializers.ModelSerializer):
def get_associated_class_name(self, obj): def get_associated_class_name(self, obj):
return obj.associated_class.atmosphere_name if obj.associated_class else None return obj.associated_class.atmosphere_name if obj.associated_class else None
def get_associated_class_id(self, obj):
return obj.associated_class.id if obj.associated_class else None
class NotificationSerializer(serializers.ModelSerializer): class NotificationSerializer(serializers.ModelSerializer):
notification_type_label = serializers.ReadOnlyField() notification_type_label = serializers.ReadOnlyField()

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="80" fill="#10b981"/>
<text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold" font-size="220" fill="white">N3</text>
</svg>

After

Width:  |  Height:  |  Size: 289 B

48
Front-End/public/sw.js Normal file
View File

@ -0,0 +1,48 @@
const CACHE_NAME = 'n3wt-school-v1';
const STATIC_ASSETS = [
'/',
'/favicon.svg',
'/favicon.ico',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
// Ne pas intercepter les requêtes API ou d'authentification
const url = new URL(event.request.url);
if (
url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/_next/') ||
event.request.method !== 'GET'
) {
return;
}
event.respondWith(
fetch(event.request)
.then((response) => {
// Mettre en cache les réponses réussies des ressources statiques
if (response.ok && url.origin === self.location.origin) {
const cloned = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, cloned));
}
return response;
})
.catch(() => caches.match(event.request))
);
});

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'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import SelectChoice from '@/components/Form/SelectChoice'; import { useRouter } from 'next/navigation';
import AcademicResults from '@/components/Grades/AcademicResults'; import { Award, Eye, Search } from 'lucide-react';
import Attendance from '@/components/Grades/Attendance'; import SectionHeader from '@/components/SectionHeader';
import Remarks from '@/components/Grades/Remarks'; import Table from '@/components/Table';
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 logger from '@/utils/logger'; import logger from '@/utils/logger';
import { import {
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
BASE_URL, BASE_URL,
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL,
} from '@/utils/Url'; } from '@/utils/Url';
import { useRouter } from 'next/navigation';
import { import {
fetchStudents, fetchStudents,
fetchStudentCompetencies, fetchStudentCompetencies,
searchStudents,
fetchAbsences, fetchAbsences,
editAbsences,
deleteAbsences,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import { useClasses } from '@/context/ClassesContext'; 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 dayjs from 'dayjs';
import { useCsrfToken } from '@/context/CsrfContext';
export default function Page() { function getPeriodString(periodValue, frequency) {
const router = useRouter(); const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1;
const csrfToken = useCsrfToken(); const schoolYear = `${year}-${year + 1}`;
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } = if (frequency === 1) return `T${periodValue}_${schoolYear}`;
useEstablishment(); if (frequency === 2) return `S${periodValue}_${schoolYear}`;
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([]);
// 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 }));
useEffect(() => {
if (selectedEstablishmentId) {
fetchStudents(selectedEstablishmentId, null, 5)
.then((studentsData) => {
setStudents(studentsData);
})
.catch((error) => logger.error('Error fetching students:', error));
}
}, [selectedEstablishmentId]);
// 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);
}
})
.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)
);
}
}, [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]);
// 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}`; if (frequency === 3) return `A_${schoolYear}`;
return ''; return '';
} }
// Callback pour justifier/non justifier une absence function calcPercent(data) {
const handleToggleJustify = (absence) => { if (!data?.data) return null;
// Inverser l'état justifié (1/3 = justifié, 2/4 = non justifié) const scores = [];
const newReason = data.data.forEach((d) =>
absence.type === 'Absence' d.categories.forEach((c) =>
? absence.justified c.competences.forEach((comp) => scores.push(comp.score ?? 0))
? 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
) )
); );
}) if (!scores.length) return null;
.catch((e) => { return Math.round(
logger.error('Erreur lors du changement de justification', e); (scores.filter((s) => s === 3).length / scores.length) * 100
}); );
}; }
// Callback pour supprimer une absence function getPeriodColumns(frequency) {
const handleDeleteAbsence = (absence) => { if (frequency === 1)
return deleteAbsences(absence.id, csrfToken) return [
.then(() => { { label: 'Trimestre 1', value: 1 },
setAllAbsences((prev) => prev.filter((a) => a.id !== absence.id)); { label: 'Trimestre 2', value: 2 },
}) { label: 'Trimestre 3', value: 3 },
.catch((e) => { ];
logger.error("Erreur lors de la suppression de l'absence", e); if (frequency === 2)
}); return [
}; { label: 'Semestre 1', value: 1 },
{ label: 'Semestre 2', value: 2 },
];
if (frequency === 3) return [{ label: 'Année', value: 1 }];
return [];
}
return ( function getCurrentPeriodValue(frequency) {
<div className="p-8 space-y-8"> const periods =
<SectionHeader {
icon={Award} 1: [
title="Suivi pédagogique" { value: 1, start: '09-01', end: '12-31' },
description="Suivez le parcours d'un élève" { value: 2, start: '01-01', end: '03-31' },
/> { value: 3, start: '04-01', end: '07-15' },
],
{/* Section haute : filtre + bouton + photo élève */} 2: [
<div className="flex flex-row gap-8 items-start"> { value: 1, start: '09-01', end: '01-31' },
{/* Colonne gauche : InputText + bouton */} { value: 2, start: '02-01', end: '07-15' },
<div className="w-4/5 flex items-end gap-4"> ],
<div className="flex-[3_3_0%]"> 3: [{ value: 1, start: '09-01', end: '07-15' }],
<InputText }[frequency] || [];
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 today = dayjs();
const start = dayjs(`${today.year()}-${period.start}`); const current = periods.find(
const end = dayjs(`${today.year()}-${period.end}`); (p) =>
const isPast = today.isAfter(end); today.isAfter(dayjs(`${today.year()}-${p.start}`).subtract(1, 'day')) &&
return { today.isBefore(dayjs(`${today.year()}-${p.end}`).add(1, 'day'))
value: period.value, );
label: period.label, return current?.value ?? null;
disabled: isPast, }
};
})} function PercentBadge({ value, loading }) {
selected={selectedPeriod || ''} if (loading) return <span className="text-gray-300 text-xs"></span>;
callback={(e) => setSelectedPeriod(Number(e.target.value))} if (value === null) return <span className="text-gray-400 text-xs"></span>;
disabled={!formData.selectedStudent} const color =
/> value >= 75
</div> ? 'bg-emerald-100 text-emerald-700'
<div className="flex-[1_1_0%] flex items-end"> : value >= 50
<Button ? 'bg-yellow-100 text-yellow-700'
primary : 'bg-red-100 text-red-600';
onClick={() => { return (
const periodString = getPeriodString( <span
selectedPeriod, 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 { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
useEstablishment();
const { getNiveauLabel } = useClasses();
const [students, setStudents] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
const ITEMS_PER_PAGE = 15;
const [currentPage, setCurrentPage] = useState(1);
const [statsMap, setStatsMap] = useState({});
const [statsLoading, setStatsLoading] = useState(false);
const [absencesMap, setAbsencesMap] = useState({});
const periodColumns = getPeriodColumns(
selectedEstablishmentEvaluationFrequency selectedEstablishmentEvaluationFrequency
); );
const url = `${FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL}?studentId=${formData.selectedStudent}&period=${periodString}`; const currentPeriodValue = getCurrentPeriodValue(
router.push(url); selectedEstablishmentEvaluationFrequency
}}
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>
{/* Section basse : liste élèves + infos */} useEffect(() => {
<div className="flex flex-row gap-8 items-start mt-8"> if (!selectedEstablishmentId) return;
{/* Colonne 1 : Liste des élèves */} fetchStudents(selectedEstablishmentId, null, 5)
<div className="w-full max-w-xs"> .then((data) => setStudents(data))
<h3 className="text-lg font-semibold text-emerald-700 mb-4"> .catch((error) => logger.error('Error fetching students:', error));
Liste des élèves
</h3> fetchAbsences(selectedEstablishmentId)
<ul className="rounded-lg bg-stone-50 shadow border border-gray-100"> .then((data) => {
{students const map = {};
.filter( (data || []).forEach((a) => {
if ([1, 2].includes(a.reason)) {
map[a.student] = (map[a.student] || 0) + 1;
}
});
setAbsencesMap(map);
})
.catch((error) => logger.error('Error fetching absences:', error));
}, [selectedEstablishmentId]);
// Fetch stats for all students × all periods
useEffect(() => {
if (!students.length || !selectedEstablishmentEvaluationFrequency) return;
setStatsLoading(true);
const frequency = selectedEstablishmentEvaluationFrequency;
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 }));
})
);
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) => (student) =>
!searchTerm || !searchTerm ||
`${student.last_name} ${student.first_name}` `${student.last_name} ${student.first_name}`
.toLowerCase() .toLowerCase()
.includes(searchTerm.toLowerCase()) .includes(searchTerm.toLowerCase())
) );
.map((student) => (
<li useEffect(() => {
key={student.id} setCurrentPage(1);
className={`flex items-center gap-4 px-4 py-3 hover:bg-emerald-100 cursor-pointer transition ${ }, [searchTerm, students]);
formData.selectedStudent === student.id
? 'bg-emerald-100 border-l-4 border-emerald-400' const totalPages = Math.ceil(filteredStudents.length / ITEMS_PER_PAGE);
: 'border-l-2 border-gray-200' const pagedStudents = filteredStudents.slice(
}`} (currentPage - 1) * ITEMS_PER_PAGE,
onClick={() => handleChange('selectedStudent', student.id)} 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}`
);
};
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 ? ( {student.photo ? (
<a
href={`${BASE_URL}${student.photo}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<img <img
src={`${BASE_URL}${student.photo}`} src={`${BASE_URL}${student.photo}`}
alt={`${student.first_name} ${student.last_name}`} alt={`${student.first_name} ${student.last_name}`}
className="w-10 h-10 object-cover rounded-full border-2 border-emerald-200" 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 text-gray-500 font-bold text-lg border-2 border-emerald-100"> <div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
{student.first_name?.[0]} <span className="text-gray-500 text-sm font-semibold">
{student.last_name?.[0]} {student.first_name?.[0]}{student.last_name?.[0]}
</span>
</div> </div>
)} )}
<div className="flex-1">
<div className="font-semibold text-emerald-800">
{student.last_name} {student.first_name}
</div> </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( case 'Élève':
(b) => b.period === periodString && b.file
);
if (bilan) {
return ( return (
<a <span className="font-semibold text-gray-700">
href={`${BASE_URL}${bilan.file}`} {student.last_name} {student.first_name}
target="_blank" </span>
rel="noopener noreferrer" );
className="ml-2 text-emerald-600 hover:text-emerald-800" case 'Niveau':
title="Télécharger le bilan de compétences" return getNiveauLabel(student.level);
onClick={(e) => e.stopPropagation()} // Pour ne pas sélectionner à nouveau l'élève 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"
> >
<FileText className="w-5 h-5" /> {student.associated_class_name}
</a> </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 null;
})()} }
</li> }
))} };
</ul>
</div> return (
{/* Colonne 2 : Reste des infos */} <div className="p-4 md:p-8 space-y-6">
<div className="flex-1"> <SectionHeader
{formData.selectedStudent && ( icon={Award}
<div className="flex flex-col gap-8 w-full justify-center items-stretch"> title="Suivi pédagogique"
<div className="w-full flex flex-row items-stretch gap-4"> description="Suivez le parcours d'un élève"
<div className="flex-1 flex items-stretch justify-center h-full"> />
<Attendance <div className="relative flex-grow max-w-md">
absences={absences} <Search
onToggleJustify={handleToggleJustify} className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
onDelete={handleDeleteAbsence} 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> </div>
<div className="flex-1 flex items-stretch justify-center h-full">
<GradesStatsCircle grades={grades} /> <Table
</div> data={pagedStudents}
</div> columns={columns}
<div className="flex items-center justify-center"> renderCell={renderCell}
<GradesDomainBarChart itemsPerPage={ITEMS_PER_PAGE}
studentCompetencies={studentCompetencies} currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
emptyMessage={
<span className="text-gray-400 text-sm">Aucun élève trouvé</span>
}
/> />
</div> </div>
</div>
)}
</div>
</div>
</div>
); );
} }

View File

@ -8,8 +8,8 @@ import {
fetchStudentCompetencies, fetchStudentCompetencies,
editStudentCompetencies, editStudentCompetencies,
} from '@/app/actions/subscriptionAction'; } from '@/app/actions/subscriptionAction';
import SectionHeader from '@/components/SectionHeader'; import { Award, ArrowLeft } from 'lucide-react';
import { Award } from 'lucide-react'; import logger from '@/utils/logger';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
@ -70,7 +70,7 @@ export default function StudentCompetenciesPage() {
'success', 'success',
'Succès' 'Succès'
); );
router.back(); router.push(`/admin/grades/${studentId}`);
}) })
.catch((error) => { .catch((error) => {
showNotification( showNotification(
@ -83,11 +83,16 @@ export default function StudentCompetenciesPage() {
return ( return (
<div className="h-full flex flex-col p-4"> <div className="h-full flex flex-col p-4">
<SectionHeader <div className="flex items-center gap-3 mb-4">
icon={Award} <button
title="Bilan de compétence" onClick={() => router.push('/admin/grades')}
description="Evaluez les compétence de l'élève" 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"> <div className="flex-1 min-h-0 flex flex-col">
<form <form
className="flex-1 min-h-0 flex flex-col" className="flex-1 min-h-0 flex flex-col"
@ -105,15 +110,6 @@ export default function StudentCompetenciesPage() {
/> />
</div> </div>
<div className="mt-6 flex justify-end"> <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" /> <Button text="Enregistrer" primary type="submit" />
</div> </div>
</form> </form>

View File

@ -29,6 +29,7 @@ import {
import { disconnect } from '@/app/actions/authAction'; import { disconnect } from '@/app/actions/authAction';
import ProtectedRoute from '@/components/ProtectedRoute'; import ProtectedRoute from '@/components/ProtectedRoute';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
import MobileTopbar from '@/components/MobileTopbar';
import { RIGHTS } from '@/utils/rights'; import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
@ -123,9 +124,12 @@ export default function Layout({ children }) {
return ( return (
<ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}> <ProtectedRoute requiredRight={[RIGHTS.ADMIN, RIGHTS.TEACHER]}>
{/* Topbar mobile (hamburger + logo) */}
<MobileTopbar onMenuClick={toggleSidebar} />
{/* Sidebar */} {/* Sidebar */}
<div <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' isSidebarOpen ? 'block' : 'hidden md:block'
}`} }`}
> >
@ -146,7 +150,7 @@ export default function Layout({ children }) {
)} )}
{/* Main container */} {/* 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} {children}
</div> </div>

View File

@ -163,7 +163,7 @@ export default function DashboardPage() {
if (isLoading) return <Loader />; if (isLoading) return <Loader />;
return ( return (
<div key={selectedEstablishmentId} className="p-6"> <div key={selectedEstablishmentId} className="p-4 md:p-6">
{/* Statistiques principales */} {/* Statistiques principales */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard <StatCard
@ -200,12 +200,12 @@ export default function DashboardPage() {
{/* Colonne de gauche : Graphique des inscriptions + Présence */} {/* Colonne de gauche : Graphique des inscriptions + Présence */}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Graphique des inscriptions */} {/* Graphique des inscriptions */}
<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">
<h2 className="text-lg font-semibold mb-6"> <h2 className="text-lg font-semibold mb-4 md:mb-6">
{t('inscriptionTrends')} {t('inscriptionTrends')}
</h2> </h2>
<div className="flex flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-6 mt-4">
<div className="flex-1 p-6"> <div className="flex-1">
<LineChart data={monthlyRegistrations} /> <LineChart data={monthlyRegistrations} />
</div> </div>
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
@ -214,13 +214,13 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Présence et assiduité */} {/* 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} /> <Attendance absences={absencesToday} readOnly={true} />
</div> </div>
</div> </div>
{/* Colonne de droite : Événements à venir */} {/* 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> <h2 className="text-lg font-semibold mb-4">{t('upcomingEvents')}</h2>
{upcomingEvents.map((event, index) => ( {upcomingEvents.map((event, index) => (
<EventCard key={index} {...event} /> <EventCard key={index} {...event} />

View File

@ -12,6 +12,7 @@ import { useEstablishment } from '@/context/EstablishmentContext';
export default function Page() { export default function Page() {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [eventData, setEventData] = useState({ const [eventData, setEventData] = useState({
title: '', title: '',
description: '', description: '',
@ -56,13 +57,17 @@ export default function Page() {
modeSet={PlanningModes.PLANNING} modeSet={PlanningModes.PLANNING}
> >
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
<ScheduleNavigation /> <ScheduleNavigation
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
/>
<Calendar <Calendar
onDateClick={initializeNewEvent} onDateClick={initializeNewEvent}
onEventClick={(event) => { onEventClick={(event) => {
setEventData(event); setEventData(event);
setIsModalOpen(true); setIsModalOpen(true);
}} }}
onOpenDrawer={() => setIsDrawerOpen(true)}
/> />
<EventModal <EventModal
isOpen={isModalOpen} isOpen={isModalOpen}

View File

@ -71,6 +71,8 @@ export default function CreateSubscriptionPage() {
const registerFormMoment = searchParams.get('school_year'); const registerFormMoment = searchParams.get('school_year');
const [students, setStudents] = useState([]); const [students, setStudents] = useState([]);
const ITEMS_PER_PAGE = 10;
const [studentsPage, setStudentsPage] = useState(1);
const [registrationDiscounts, setRegistrationDiscounts] = useState([]); const [registrationDiscounts, setRegistrationDiscounts] = useState([]);
const [tuitionDiscounts, setTuitionDiscounts] = useState([]); const [tuitionDiscounts, setTuitionDiscounts] = useState([]);
const [registrationFees, setRegistrationFees] = useState([]); const [registrationFees, setRegistrationFees] = useState([]);
@ -179,6 +181,8 @@ export default function CreateSubscriptionPage() {
formDataRef.current = formData; formDataRef.current = formData;
}, [formData]); }, [formData]);
useEffect(() => { setStudentsPage(1); }, [students]);
useEffect(() => { useEffect(() => {
if (!formData.guardianEmail) { if (!formData.guardianEmail) {
// Si l'email est vide, réinitialiser existingProfileId et existingProfileInSchool // Si l'email est vide, réinitialiser existingProfileId et existingProfileInSchool
@ -709,6 +713,9 @@ export default function CreateSubscriptionPage() {
return finalAmount.toFixed(2); 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) { if (isLoading === true) {
return <Loader />; // Affichez le composant Loader return <Loader />; // Affichez le composant Loader
} }
@ -869,7 +876,7 @@ export default function CreateSubscriptionPage() {
{!isNewResponsable && ( {!isNewResponsable && (
<div className="mt-4"> <div className="mt-4">
<Table <Table
data={students} data={pagedStudents}
columns={[ columns={[
{ {
name: 'photo', name: 'photo',
@ -927,6 +934,10 @@ export default function CreateSubscriptionPage() {
: '' : ''
} }
selectedRows={selectedStudent ? [selectedStudent.id] : []} // Assurez-vous que selectedRows est un tableau selectedRows={selectedStudent ? [selectedStudent.id] : []} // Assurez-vous que selectedRows est un tableau
itemsPerPage={ITEMS_PER_PAGE}
currentPage={studentsPage}
totalPages={studentsTotalPages}
onPageChange={setStudentsPage}
/> />
{selectedStudent && ( {selectedStudent && (

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Sidebar from '@/components/Sidebar'; import Sidebar from '@/components/Sidebar';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { MessageSquare, Settings, Home, Menu } from 'lucide-react'; import { MessageSquare, Settings, Home } from 'lucide-react';
import { import {
FE_PARENTS_HOME_URL, FE_PARENTS_HOME_URL,
FE_PARENTS_MESSAGERIE_URL FE_PARENTS_MESSAGERIE_URL
@ -11,6 +11,7 @@ import {
import ProtectedRoute from '@/components/ProtectedRoute'; import ProtectedRoute from '@/components/ProtectedRoute';
import { disconnect } from '@/app/actions/authAction'; import { disconnect } from '@/app/actions/authAction';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import MobileTopbar from '@/components/MobileTopbar';
import { RIGHTS } from '@/utils/rights'; import { RIGHTS } from '@/utils/rights';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
import Footer from '@/components/Footer'; import Footer from '@/components/Footer';
@ -73,17 +74,12 @@ export default function Layout({ children }) {
return ( return (
<ProtectedRoute requiredRight={RIGHTS.PARENT}> <ProtectedRoute requiredRight={RIGHTS.PARENT}>
{/* Bouton hamburger pour mobile */} {/* Topbar mobile (hamburger + logo) */}
<button <MobileTopbar onMenuClick={toggleSidebar} />
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>
{/* Sidebar */} {/* Sidebar */}
<div <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' isSidebarOpen ? 'block' : 'hidden md:block'
}`} }`}
> >
@ -104,7 +100,7 @@ export default function Layout({ children }) {
{/* Main container */} {/* Main container */}
<div <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} {children}
</div> </div>

View File

@ -1,12 +1,19 @@
import React from 'react'; import React from 'react';
import { getMessages } from 'next-intl/server'; import { getMessages } from 'next-intl/server';
import Providers from '@/components/Providers'; import Providers from '@/components/Providers';
import ServiceWorkerRegister from '@/components/ServiceWorkerRegister';
import '@/css/tailwind.css'; import '@/css/tailwind.css';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
export const metadata = { export const metadata = {
title: 'N3WT-SCHOOL', title: 'N3WT-SCHOOL',
description: "Gestion de l'école", description: "Gestion de l'école",
manifest: '/manifest.webmanifest',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'N3WT School',
},
icons: { icons: {
icon: [ icon: [
{ {
@ -14,10 +21,11 @@ export const metadata = {
type: 'image/svg+xml', type: 'image/svg+xml',
}, },
{ {
url: '/favicon.ico', // Fallback pour les anciens navigateurs url: '/favicon.ico',
sizes: 'any', sizes: 'any',
}, },
], ],
apple: '/icons/icon.svg',
}, },
}; };
@ -32,6 +40,7 @@ export default async function RootLayout({ children, params }) {
<Providers messages={messages} locale={locale} session={params.session}> <Providers messages={messages} locale={locale} session={params.session}>
{children} {children}
</Providers> </Providers>
<ServiceWorkerRegister />
</body> </body>
</html> </html>
); );

View File

@ -0,0 +1,26 @@
export default function manifest() {
return {
name: 'N3WT School',
short_name: 'N3WT School',
description: "Gestion de l'école",
start_url: '/',
display: 'standalone',
background_color: '#f0fdf4',
theme_color: '#10b981',
orientation: 'portrait',
icons: [
{
src: '/icons/icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any',
},
{
src: '/icons/icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'maskable',
},
],
};
}

View File

@ -4,6 +4,7 @@ import WeekView from '@/components/Calendar/WeekView';
import MonthView from '@/components/Calendar/MonthView'; import MonthView from '@/components/Calendar/MonthView';
import YearView from '@/components/Calendar/YearView'; import YearView from '@/components/Calendar/YearView';
import PlanningView from '@/components/Calendar/PlanningView'; import PlanningView from '@/components/Calendar/PlanningView';
import DayView from '@/components/Calendar/DayView';
import ToggleView from '@/components/ToggleView'; import ToggleView from '@/components/ToggleView';
import { ChevronLeft, ChevronRight, Plus, ChevronDown } from 'lucide-react'; import { ChevronLeft, ChevronRight, Plus, ChevronDown } from 'lucide-react';
import { import {
@ -11,9 +12,11 @@ import {
addWeeks, addWeeks,
addMonths, addMonths,
addYears, addYears,
addDays,
subWeeks, subWeeks,
subMonths, subMonths,
subYears, subYears,
subDays,
getWeek, getWeek,
setMonth, setMonth,
setYear, setYear,
@ -22,7 +25,7 @@ import { fr } from 'date-fns/locale';
import { AnimatePresence, motion } from 'framer-motion'; // Ajouter cet import import { AnimatePresence, motion } from 'framer-motion'; // Ajouter cet import
import logger from '@/utils/logger'; import logger from '@/utils/logger';
const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' }) => { const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName = '', onOpenDrawer = () => {} }) => {
const { const {
currentDate, currentDate,
setCurrentDate, setCurrentDate,
@ -35,6 +38,14 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
} = usePlanning(); } = usePlanning();
const [visibleEvents, setVisibleEvents] = useState([]); const [visibleEvents, setVisibleEvents] = useState([]);
const [showDatePicker, setShowDatePicker] = useState(false); const [showDatePicker, setShowDatePicker] = useState(false);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768);
check();
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
// Ajouter ces fonctions pour la gestion des mois et années // Ajouter ces fonctions pour la gestion des mois et années
const months = Array.from({ length: 12 }, (_, i) => ({ const months = Array.from({ length: 12 }, (_, i) => ({
@ -68,7 +79,12 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
const navigateDate = (direction) => { const navigateDate = (direction) => {
const getNewDate = () => { const getNewDate = () => {
switch (viewType) { const effectiveView = isMobile ? 'day' : viewType;
switch (effectiveView) {
case 'day':
return direction === 'next'
? addDays(currentDate, 1)
: subDays(currentDate, 1);
case 'week': case 'week':
return direction === 'next' return direction === 'next'
? addWeeks(currentDate, 1) ? addWeeks(currentDate, 1)
@ -91,8 +107,9 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
<div className="flex items-center justify-between p-4 bg-white sticky top-0 z-30 border-b shadow-sm h-[64px]"> {/* Header uniquement sur desktop */}
{/* Navigation à gauche */} <div className="hidden md:flex items-center justify-between p-4 bg-white sticky top-0 z-30 border-b shadow-sm h-[64px]">
<>
{planningMode === PlanningModes.PLANNING && ( {planningMode === PlanningModes.PLANNING && (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<button <button
@ -101,10 +118,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
> >
Aujourd&apos;hui Aujourd&apos;hui
</button> </button>
<button <button onClick={() => navigateDate('prev')} className="p-2 hover:bg-gray-100 rounded-full">
onClick={() => navigateDate('prev')}
className="p-2 hover:bg-gray-100 rounded-full"
>
<ChevronLeft className="w-5 h-5" /> <ChevronLeft className="w-5 h-5" />
</button> </button>
<div className="relative"> <div className="relative">
@ -113,11 +127,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md" className="flex items-center gap-1 px-2 py-1 hover:bg-gray-100 rounded-md"
> >
<h2 className="text-xl font-semibold"> <h2 className="text-xl font-semibold">
{format( {format(currentDate, viewType === 'year' ? 'yyyy' : 'MMMM yyyy', { locale: fr })}
currentDate,
viewType === 'year' ? 'yyyy' : 'MMMM yyyy',
{ locale: fr }
)}
</h2> </h2>
<ChevronDown className="w-4 h-4" /> <ChevronDown className="w-4 h-4" />
</button> </button>
@ -127,11 +137,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
<div className="p-2 border-b"> <div className="p-2 border-b">
<div className="grid grid-cols-3 gap-1"> <div className="grid grid-cols-3 gap-1">
{months.map((month) => ( {months.map((month) => (
<button <button key={month.value} onClick={() => handleMonthSelect(month.value)} className="p-2 text-sm hover:bg-gray-100 rounded-md">
key={month.value}
onClick={() => handleMonthSelect(month.value)}
className="p-2 text-sm hover:bg-gray-100 rounded-md"
>
{month.label} {month.label}
</button> </button>
))} ))}
@ -141,11 +147,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
<div className="p-2"> <div className="p-2">
<div className="grid grid-cols-3 gap-1"> <div className="grid grid-cols-3 gap-1">
{years.map((year) => ( {years.map((year) => (
<button <button key={year.value} onClick={() => handleYearSelect(year.value)} className="p-2 text-sm hover:bg-gray-100 rounded-md">
key={year.value}
onClick={() => handleYearSelect(year.value)}
className="p-2 text-sm hover:bg-gray-100 rounded-md"
>
{year.label} {year.label}
</button> </button>
))} ))}
@ -154,16 +156,12 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
</div> </div>
)} )}
</div> </div>
<button <button onClick={() => navigateDate('next')} className="p-2 hover:bg-gray-100 rounded-full">
onClick={() => navigateDate('next')}
className="p-2 hover:bg-gray-100 rounded-full"
>
<ChevronRight className="w-5 h-5" /> <ChevronRight className="w-5 h-5" />
</button> </button>
</div> </div>
)} )}
{/* Centre : numéro de semaine ou classe/niveau */}
<div className="flex-1 flex justify-center"> <div className="flex-1 flex justify-center">
{((planningMode === PlanningModes.PLANNING || planningMode === PlanningModes.CLASS_SCHEDULE) && viewType === 'week' && !parentView) && ( {((planningMode === PlanningModes.PLANNING || planningMode === PlanningModes.CLASS_SCHEDULE) && viewType === 'week' && !parentView) && (
<div className="flex items-center gap-1 text-sm font-medium text-gray-600"> <div className="flex items-center gap-1 text-sm font-medium text-gray-600">
@ -175,13 +173,11 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
)} )}
{parentView && ( {parentView && (
<span className="px-2 py-1 bg-gray-100 rounded-md text-base font-semibold"> <span className="px-2 py-1 bg-gray-100 rounded-md text-base font-semibold">
{/* À adapter selon les props disponibles */}
{planningClassName} {planningClassName}
</span> </span>
)} )}
</div> </div>
{/* Contrôles à droite */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{planningMode === PlanningModes.PLANNING && ( {planningMode === PlanningModes.PLANNING && (
<ToggleView viewType={viewType} setViewType={setViewType} /> <ToggleView viewType={viewType} setViewType={setViewType} />
@ -195,12 +191,30 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
</button> </button>
)} )}
</div> </div>
</>
</div> </div>
{/* Contenu scrollable */} {/* Contenu scrollable */}
<div className="flex-1 max-h-[calc(100vh-192px)] overflow-hidden"> <div className="flex-1 max-h-[calc(100vh-192px)] overflow-hidden">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{viewType === 'week' && ( {isMobile && (
<motion.div
key="day"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2 }}
className="h-full flex flex-col"
>
<DayView
onDateClick={onDateClick}
onEventClick={onEventClick}
events={visibleEvents}
onOpenDrawer={onOpenDrawer}
/>
</motion.div>
)}
{!isMobile && viewType === 'week' && (
<motion.div <motion.div
key="week" key="week"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -216,7 +230,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
/> />
</motion.div> </motion.div>
)} )}
{viewType === 'month' && ( {!isMobile && viewType === 'month' && (
<motion.div <motion.div
key="month" key="month"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -231,7 +245,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
/> />
</motion.div> </motion.div>
)} )}
{viewType === 'year' && ( {!isMobile && viewType === 'year' && (
<motion.div <motion.div
key="year" key="year"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -242,7 +256,7 @@ const Calendar = ({ modeSet, onDateClick, onEventClick, planningClassName='' })
<YearView onDateClick={onDateClick} events={visibleEvents} /> <YearView onDateClick={onDateClick} events={visibleEvents} />
</motion.div> </motion.div>
)} )}
{viewType === 'planning' && ( {!isMobile && viewType === 'planning' && (
<motion.div <motion.div
key="planning" key="planning"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}

View File

@ -0,0 +1,230 @@
import React, { useEffect, useState, useRef } from 'react';
import { usePlanning } from '@/context/PlanningContext';
import {
format,
startOfWeek,
addDays,
subDays,
isSameDay,
isToday,
} from 'date-fns';
import { fr } from 'date-fns/locale';
import { getWeekEvents } from '@/utils/events';
import { CalendarDays, ChevronLeft, ChevronRight, Plus } from 'lucide-react';
const DayView = ({ onDateClick, onEventClick, events, onOpenDrawer }) => {
const { currentDate, setCurrentDate, parentView } = usePlanning();
const [currentTime, setCurrentTime] = useState(new Date());
const scrollRef = useRef(null);
const timeSlots = Array.from({ length: 24 }, (_, i) => i);
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 });
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
const isCurrentDay = isSameDay(currentDate, new Date());
const dayEvents = getWeekEvents(currentDate, events) || [];
useEffect(() => {
const interval = setInterval(() => setCurrentTime(new Date()), 60000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (scrollRef.current && isCurrentDay) {
const currentHour = new Date().getHours();
setTimeout(() => {
scrollRef.current.scrollTop = currentHour * 80 - 200;
}, 0);
}
}, [currentDate, isCurrentDay]);
const getCurrentTimePosition = () => {
const hours = currentTime.getHours();
const minutes = currentTime.getMinutes();
return `${(hours + minutes / 60) * 5}rem`;
};
const calculateEventStyle = (event, allDayEvents) => {
const start = new Date(event.start);
const end = new Date(event.end);
const startMinutes = (start.getMinutes() / 60) * 5;
const duration = ((end - start) / (1000 * 60 * 60)) * 5;
const overlapping = allDayEvents.filter((other) => {
if (other.id === event.id) return false;
const oStart = new Date(other.start);
const oEnd = new Date(other.end);
return !(oEnd <= start || oStart >= end);
});
const eventIndex = overlapping.findIndex((e) => e.id > event.id) + 1;
const total = overlapping.length + 1;
return {
height: `${Math.max(duration, 1.5)}rem`,
position: 'absolute',
width: `calc((100% / ${total}) - 4px)`,
left: `calc((100% / ${total}) * ${eventIndex})`,
backgroundColor: `${event.color}15`,
borderLeft: `3px solid ${event.color}`,
borderRadius: '0.25rem',
zIndex: 1,
transform: `translateY(${startMinutes}rem)`,
};
};
return (
<div className="flex flex-col h-full">
{/* Barre de navigation (remplace le header Calendar sur mobile) */}
<div className="flex items-center justify-between px-3 py-2 bg-white border-b shrink-0">
<button
onClick={onOpenDrawer}
className="p-2 hover:bg-gray-100 rounded-full"
aria-label="Ouvrir les plannings"
>
<CalendarDays className="w-5 h-5 text-gray-600" />
</button>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentDate(subDays(currentDate, 1))}
className="p-2 hover:bg-gray-100 rounded-full"
>
<ChevronLeft className="w-5 h-5" />
</button>
<label className="relative cursor-pointer">
<span className="px-2 py-1 text-sm font-semibold text-gray-800 hover:bg-gray-100 rounded-md capitalize">
{format(currentDate, 'EEE d MMM', { locale: fr })}
</span>
<input
type="date"
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
value={format(currentDate, 'yyyy-MM-dd')}
onChange={(e) => {
if (e.target.value) setCurrentDate(new Date(e.target.value + 'T12:00:00'));
}}
/>
</label>
<button
onClick={() => setCurrentDate(addDays(currentDate, 1))}
className="p-2 hover:bg-gray-100 rounded-full"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
<button
onClick={() => onDateClick?.(currentDate)}
className="w-9 h-9 flex items-center justify-center bg-emerald-600 text-white rounded-full hover:bg-emerald-700 shadow-md transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Bandeau jours de la semaine */}
<div className="flex gap-1 px-2 py-2 bg-white border-b overflow-x-auto shrink-0">
{weekDays.map((day) => (
<button
key={day.toISOString()}
onClick={() => setCurrentDate(day)}
className={`flex flex-col items-center min-w-[2.75rem] px-1 py-1.5 rounded-xl transition-colors ${
isSameDay(day, currentDate)
? 'bg-emerald-600 text-white'
: isToday(day)
? 'border border-emerald-400 text-emerald-600'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<span className="text-xs font-medium uppercase">
{format(day, 'EEE', { locale: fr })}
</span>
<span className="text-sm font-bold">{format(day, 'd')}</span>
</button>
))}
</div>
{/* Grille horaire */}
<div ref={scrollRef} className="flex-1 overflow-y-auto relative">
{isCurrentDay && (
<div
className="absolute left-0 right-0 z-10 border-emerald-500 border pointer-events-none"
style={{ top: getCurrentTimePosition() }}
>
<div className="absolute -left-2 -top-2 w-2 h-2 rounded-full bg-emerald-500" />
</div>
)}
<div
className="grid w-full bg-gray-100 gap-[1px]"
style={{ gridTemplateColumns: '2.5rem 1fr' }}
>
{timeSlots.map((hour) => (
<React.Fragment key={hour}>
<div className="h-20 p-1 text-right text-sm text-gray-500 bg-gray-100 font-medium">
{`${hour.toString().padStart(2, '0')}:00`}
</div>
<div
className={`h-20 relative border-b border-gray-100 ${
isCurrentDay ? 'bg-emerald-50/30' : 'bg-white'
}`}
onClick={
parentView
? undefined
: () => {
const date = new Date(currentDate);
date.setHours(hour);
date.setMinutes(0);
onDateClick(date);
}
}
>
{dayEvents
.filter((e) => new Date(e.start).getHours() === hour)
.map((event) => (
<div
key={event.id}
className="rounded-sm overflow-hidden cursor-pointer hover:shadow-lg"
style={calculateEventStyle(event, dayEvents)}
onClick={
parentView
? undefined
: (e) => {
e.stopPropagation();
onEventClick(event);
}
}
>
<div className="p-1">
<div
className="font-semibold text-xs truncate"
style={{ color: event.color }}
>
{event.title}
</div>
<div
className="text-xs"
style={{ color: event.color, opacity: 0.75 }}
>
{format(new Date(event.start), 'HH:mm')} {' '}
{format(new Date(event.end), 'HH:mm')}
</div>
{event.location && (
<div
className="text-xs truncate"
style={{ color: event.color, opacity: 0.75 }}
>
{event.location}
</div>
)}
</div>
</div>
))}
</div>
</React.Fragment>
))}
</div>
</div>
</div>
);
};
export default DayView;

View File

@ -253,7 +253,7 @@ export default function EventModal({
)} )}
{/* Dates */} {/* Dates */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Début Début

View File

@ -75,24 +75,37 @@ const MonthView = ({ onDateClick, onEventClick }) => {
); );
}; };
const dayLabels = [
{ short: 'L', long: 'Lun' },
{ short: 'M', long: 'Mar' },
{ short: 'M', long: 'Mer' },
{ short: 'J', long: 'Jeu' },
{ short: 'V', long: 'Ven' },
{ short: 'S', long: 'Sam' },
{ short: 'D', long: 'Dim' },
];
return ( return (
<div className="h-full flex flex-col border border-gray-200 rounded-lg bg-white"> <div className="h-full flex flex-col border border-gray-200 rounded-lg bg-white overflow-x-auto">
<div className="min-w-[280px]">
{/* En-tête des jours de la semaine */} {/* En-tête des jours de la semaine */}
<div className="grid grid-cols-7 border-b"> <div className="grid grid-cols-7 border-b">
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map((day) => ( {dayLabels.map((day, i) => (
<div <div
key={day} key={i}
className="p-2 text-center text-sm font-medium text-gray-500" className="p-1 sm:p-2 text-center text-xs sm:text-sm font-medium text-gray-500"
> >
{day} <span className="sm:hidden">{day.short}</span>
<span className="hidden sm:inline">{day.long}</span>
</div> </div>
))} ))}
</div> </div>
{/* Grille des jours */} {/* Grille des jours */}
<div className="flex-1 grid grid-cols-7 grid-rows-[repeat(6,1fr)]"> <div className="grid grid-cols-7 grid-rows-[repeat(6,1fr)]">
{days.map((day) => renderDay(day))} {days.map((day) => renderDay(day))}
</div> </div>
</div> </div>
</div>
); );
}; };

View File

@ -32,7 +32,7 @@ const PlanningView = ({ events, onEventClick }) => {
return ( return (
<div className="bg-white h-full overflow-auto"> <div className="bg-white h-full overflow-auto">
<table className="w-full border-collapse"> <table className="min-w-full border-collapse">
<thead className="bg-gray-50 sticky top-0 z-10"> <thead className="bg-gray-50 sticky top-0 z-10">
<tr> <tr>
<th className="py-3 px-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b"> <th className="py-3 px-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b">

View File

@ -3,7 +3,7 @@ import { usePlanning, PlanningModes } from '@/context/PlanningContext';
import { Plus, Edit2, Eye, EyeOff, Check, X } from 'lucide-react'; import { Plus, Edit2, Eye, EyeOff, Check, X } from 'lucide-react';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
export default function ScheduleNavigation({ classes, modeSet = 'event' }) { export default function ScheduleNavigation({ classes, modeSet = 'event', isOpen = false, onClose = () => {} }) {
const { const {
schedules, schedules,
selectedSchedule, selectedSchedule,
@ -62,22 +62,10 @@ export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
} }
}; };
return ( const title = planningMode === PlanningModes.CLASS_SCHEDULE ? 'Emplois du temps' : 'Plannings';
<nav className="w-64 border-r p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold">
{planningMode === PlanningModes.CLASS_SCHEDULE
? 'Emplois du temps'
: 'Plannings'}
</h2>
<button
onClick={() => setIsAddingNew(true)}
className="p-1 hover:bg-gray-100 rounded"
>
<Plus className="w-4 h-4" />
</button>
</div>
const listContent = (
<>
{isAddingNew && ( {isAddingNew && (
<div className="mb-4 p-2 border rounded"> <div className="mb-4 p-2 border rounded">
<input <input
@ -251,6 +239,50 @@ export default function ScheduleNavigation({ classes, modeSet = 'event' }) {
</li> </li>
))} ))}
</ul> </ul>
</>
);
return (
<>
{/* Desktop : sidebar fixe */}
<nav className="hidden md:flex flex-col w-64 border-r p-4 h-full overflow-y-auto shrink-0">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold">{title}</h2>
<button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded">
<Plus className="w-4 h-4" />
</button>
</div>
{listContent}
</nav> </nav>
{/* Mobile : drawer en overlay */}
<div
className={`md:hidden fixed inset-0 z-50 transition-opacity duration-200 ${
isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`}
>
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div
className={`absolute left-0 top-0 bottom-0 w-72 bg-white shadow-xl flex flex-col transition-transform duration-200 ${
isOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex items-center justify-between p-4 border-b shrink-0">
<h2 className="font-semibold">{title}</h2>
<div className="flex items-center gap-1">
<button onClick={() => setIsAddingNew(true)} className="p-1 hover:bg-gray-100 rounded">
<Plus className="w-4 h-4" />
</button>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded">
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{listContent}
</div>
</div>
</div>
</>
); );
} }

View File

@ -36,7 +36,7 @@ const YearView = ({ onDateClick }) => {
}; };
return ( return (
<div className="grid grid-cols-4 gap-4 p-4"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 p-4">
{months.map((month) => ( {months.map((month) => (
<MonthCard <MonthCard
key={month.getTime()} key={month.getTime()}

View File

@ -15,27 +15,28 @@ export default function LineChart({ data }) {
.filter((idx) => idx !== -1); .filter((idx) => idx !== -1);
return ( return (
<div <div className="w-full flex space-x-4">
className="w-full flex items-end space-x-4"
style={{ height: chartHeight }}
>
{data.map((point, idx) => { {data.map((point, idx) => {
const barHeight = Math.max((point.value / maxValue) * chartHeight, 8); // min 8px const barHeight = Math.max((point.value / maxValue) * chartHeight, 8);
const isMax = maxIndices.includes(idx); const isMax = maxIndices.includes(idx);
return ( return (
<div key={idx} className="flex flex-col items-center flex-1"> <div key={idx} className="flex flex-col items-center flex-1">
{/* Valeur au-dessus de la barre */} {/* Valeur au-dessus de la barre — hors de la zone hauteur fixe */}
<span className="text-xs mb-1 text-gray-700 font-semibold"> <span className="text-xs mb-1 text-gray-700 font-semibold">
{point.value} {point.value}
</span> </span>
{/* Zone barres à hauteur fixe, alignées en bas */}
<div
className="w-full flex items-end justify-center"
style={{ height: chartHeight }}
>
<div <div
className={`${isMax ? 'bg-emerald-400' : 'bg-blue-400'} rounded-t w-4`} className={`${isMax ? 'bg-emerald-400' : 'bg-blue-400'} rounded-t w-4`}
style={{ style={{ height: `${barHeight}px`, transition: 'height 0.3s' }}
height: `${barHeight}px`,
transition: 'height 0.3s',
}}
title={`${point.month}: ${point.value}`} title={`${point.month}: ${point.value}`}
/> />
</div>
{/* Label mois en dessous */}
<span className="text-xs mt-1 text-gray-600">{point.month}</span> <span className="text-xs mt-1 text-gray-600">{point.month}</span>
</div> </div>
); );

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { MessageSquare, Plus, Search, Trash2 } from 'lucide-react'; import { MessageSquare, Plus, Search, Trash2, ArrowLeft } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useCsrfToken } from '@/context/CsrfContext'; import { useCsrfToken } from '@/context/CsrfContext';
import { useNotification } from '@/context/NotificationContext'; import { useNotification } from '@/context/NotificationContext';
@ -99,6 +99,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
// États pour la confirmation de suppression // États pour la confirmation de suppression
const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false); const [confirmDeleteVisible, setConfirmDeleteVisible] = useState(false);
const [conversationToDelete, setConversationToDelete] = useState(null); const [conversationToDelete, setConversationToDelete] = useState(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(true);
// Refs // Refs
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
@ -541,6 +542,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
logger.debug('🔄 Sélection de la conversation:', conversation); logger.debug('🔄 Sélection de la conversation:', conversation);
setSelectedConversation(conversation); setSelectedConversation(conversation);
setTypingUsers([]); setTypingUsers([]);
setIsMobileSidebarOpen(false);
// Utiliser id ou conversation_id selon ce qui est disponible // Utiliser id ou conversation_id selon ce qui est disponible
const conversationId = conversation.id || conversation.conversation_id; const conversationId = conversation.id || conversation.conversation_id;
@ -828,7 +830,7 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
return ( return (
<div className="flex h-full bg-white"> <div className="flex h-full bg-white">
{/* Sidebar des conversations */} {/* Sidebar des conversations */}
<div className="w-80 bg-gray-50 border-r border-gray-200 flex flex-col"> <div className={`${isMobileSidebarOpen ? 'flex' : 'hidden'} md:flex w-full md:w-80 bg-gray-50 border-r border-gray-200 flex-col`}>
{/* En-tête */} {/* En-tête */}
<div className="p-4 border-b border-gray-200"> <div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@ -986,12 +988,20 @@ const InstantChat = ({ userProfileId, establishmentId }) => {
</div> </div>
{/* Zone de chat principale */} {/* Zone de chat principale */}
<div className="flex-1 flex flex-col"> <div className={`${!isMobileSidebarOpen ? 'flex' : 'hidden'} md:flex flex-1 flex-col`}>
{selectedConversation ? ( {selectedConversation ? (
<> <>
{/* En-tête de la conversation */} {/* En-tête de la conversation */}
<div className="p-4 border-b border-gray-200 bg-white"> <div className="p-4 border-b border-gray-200 bg-white">
<div className="flex items-center"> <div className="flex items-center">
{/* Bouton retour liste sur mobile */}
<button
onClick={() => setIsMobileSidebarOpen(true)}
className="mr-3 p-1 rounded hover:bg-gray-100 md:hidden"
aria-label="Retour aux conversations"
>
<ArrowLeft className="w-5 h-5 text-gray-600" />
</button>
<div className="flex items-center"> <div className="flex items-center">
<div className="relative"> <div className="relative">
<img <img

View File

@ -57,14 +57,14 @@ export default function FlashNotification({
animate={{ opacity: 1, x: 0 }} // Animation visible animate={{ opacity: 1, x: 0 }} // Animation visible
exit={{ opacity: 0, x: 50 }} // Animation de sortie exit={{ opacity: 0, x: 50 }} // Animation de sortie
transition={{ duration: 0.3 }} // Durée des animations transition={{ duration: 0.3 }} // Durée des animations
className="fixed top-5 right-5 flex items-stretch rounded-lg shadow-lg bg-white z-50 border border-gray-200" className="fixed top-5 right-2 left-2 sm:left-auto sm:right-5 sm:max-w-sm flex items-stretch rounded-lg shadow-lg bg-white z-50 border border-gray-200"
> >
{/* Rectangle gauche avec l'icône */} {/* Rectangle gauche avec l'icône */}
<div className={`flex items-center justify-center w-14 ${bg}`}> <div className={`flex items-center justify-center w-14 ${bg}`}>
{icon} {icon}
</div> </div>
{/* Zone de texte */} {/* Zone de texte */}
<div className="flex-1 w-96 p-4"> <div className="flex-1 min-w-0 p-4">
<p className="font-bold text-black">{title}</p> <p className="font-bold text-black">{title}</p>
<p className="text-gray-700">{message}</p> <p className="text-gray-700">{message}</p>
{type === 'error' && errorCode && ( {type === 'error' && errorCode && (

View File

@ -2,7 +2,7 @@ import Logo from '@/components/Logo';
export default function Footer({ softwareName, softwareVersion }) { export default function Footer({ softwareName, softwareVersion }) {
return ( return (
<footer className="absolute bottom-0 left-64 right-0 h-16 bg-white border-t border-gray-200 flex items-center justify-center box-border"> <footer className="absolute bottom-0 left-0 md:left-64 right-0 h-16 bg-white border-t border-gray-200 flex items-center justify-center box-border">
<div className="text-sm font-light"> <div className="text-sm font-light">
<span> <span>
&copy; {new Date().getFullYear()} N3WT-INNOV Tous droits réservés. &copy; {new Date().getFullYear()} N3WT-INNOV Tous droits réservés.

View File

@ -1,5 +1,5 @@
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { BookOpen, CheckCircle, AlertCircle, Clock } from 'lucide-react'; import { BookOpen, CheckCircle, AlertCircle, Clock, ChevronDown, ChevronRight } from 'lucide-react';
import RadioList from '@/components/Form/RadioList'; import RadioList from '@/components/Form/RadioList';
const LEVELS = [ const LEVELS = [
@ -86,9 +86,10 @@ export default function GradeView({ data, grades, onGradeChange }) {
{domaine.domaine_nom} {domaine.domaine_nom}
</span> </span>
</div> </div>
<span className="text-emerald-700 text-xl"> {openDomains[domaine.domaine_id]
{openDomains[domaine.domaine_id] ? '▼' : '►'} ? <ChevronDown className="w-5 h-5 text-emerald-700" />
</span> : <ChevronRight className="w-5 h-5 text-emerald-700" />
}
</div> </div>
{openDomains[domaine.domaine_id] && ( {openDomains[domaine.domaine_id] && (
<div className="mt-4"> <div className="mt-4">
@ -99,7 +100,10 @@ export default function GradeView({ data, grades, onGradeChange }) {
className="flex items-center gap-2 text-lg font-semibold text-emerald-700 mb-4 hover:underline" className="flex items-center gap-2 text-lg font-semibold text-emerald-700 mb-4 hover:underline"
onClick={() => toggleCategory(categorie.categorie_id)} onClick={() => toggleCategory(categorie.categorie_id)}
> >
{openCategories[categorie.categorie_id] ? '▼' : '►'}{' '} {openCategories[categorie.categorie_id]
? <ChevronDown className="w-4 h-4" />
: <ChevronRight className="w-4 h-4" />
}
{categorie.categorie_nom} {categorie.categorie_nom}
</button> </button>
{openCategories[categorie.categorie_id] && ( {openCategories[categorie.categorie_id] && (

View File

@ -0,0 +1,18 @@
'use client';
import { Menu } from 'lucide-react';
import ProfileSelector from '@/components/ProfileSelector';
export default function MobileTopbar({ onMenuClick }) {
return (
<header className="fixed top-0 left-0 right-0 z-40 h-14 bg-white border-b border-gray-200 flex items-center justify-between px-4 md:hidden">
<button
onClick={onMenuClick}
className="p-2 rounded-md hover:bg-gray-100 border border-gray-200"
aria-label="Ouvrir le menu"
>
<Menu size={20} />
</button>
<ProfileSelector compact />
</header>
);
}

View File

@ -6,11 +6,11 @@ const Pagination = ({ currentPage, totalPages, onPageChange }) => {
const pages = Array.from({ length: totalPages }, (_, i) => i + 1); const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
return ( return (
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between"> <div className="px-4 sm:px-6 py-4 border-t border-gray-200 flex flex-wrap items-center justify-between gap-2">
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">
{t('page')} {currentPage} {t('of')} {pages.length} {t('page')} {currentPage} {t('of')} {pages.length}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-1 sm:gap-2">
{currentPage > 1 && ( {currentPage > 1 && (
<PaginationButton <PaginationButton
text={t('previous')} text={t('previous')}

View File

@ -13,7 +13,7 @@ import {
BASE_URL, BASE_URL,
} from '@/utils/Url'; } from '@/utils/Url';
const ProfileSelector = ({ onRoleChange, className = '' }) => { const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
const { const {
establishments, establishments,
selectedRoleId, selectedRoleId,
@ -103,11 +103,29 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
// Suppression du tronquage JS, on utilise uniquement CSS // Suppression du tronquage JS, on utilise uniquement CSS
const isSingleRole = establishments && establishments.length === 1; const isSingleRole = establishments && establishments.length === 1;
return ( const buttonContent = compact ? (
<div className={`relative ${className}`}> /* Mode compact : avatar seul pour la topbar mobile */
<DropdownMenu <div className="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-md hover:bg-gray-100">
buttonContent={ <div className="relative">
<div className="h-16 flex items-center gap-2 cursor-pointer px-4 bg-white h-24"> <Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
alt="Profile"
className="w-8 h-8 rounded-full object-cover shadow-md"
width={32}
height={32}
/>
<div
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${getStatusColor()}`}
title={getStatusTitle()}
/>
</div>
<ChevronDown
className={`w-4 h-4 text-gray-500 transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : 'rotate-0'}`}
/>
</div>
) : (
/* Mode normal : avatar + infos texte */
<div className="flex items-center gap-2 cursor-pointer px-4 bg-white h-24">
<div className="relative"> <div className="relative">
<Image <Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)} src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
@ -116,7 +134,6 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
width={64} width={64}
height={64} height={64}
/> />
{/* Bulle de statut de connexion au chat */}
<div <div
className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white ${getStatusColor()}`} className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white ${getStatusColor()}`}
title={getStatusTitle()} title={getStatusTitle()}
@ -146,7 +163,12 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
className={`w-5 h-5 transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : 'rotate-0'}`} className={`w-5 h-5 transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : 'rotate-0'}`}
/> />
</div> </div>
} );
return (
<div className={`relative ${className}`}>
<DropdownMenu
buttonContent={buttonContent}
items={ items={
isSingleRole isSingleRole
? [ ? [
@ -190,7 +212,10 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
] ]
} }
buttonClassName="w-full" buttonClassName="w-full"
menuClassName="absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10" menuClassName={compact
? 'absolute right-0 mt-2 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg z-50'
: 'absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10'
}
dropdownOpen={dropdownOpen} dropdownOpen={dropdownOpen}
setDropdownOpen={setDropdownOpen} setDropdownOpen={setDropdownOpen}
/> />

View File

@ -0,0 +1,15 @@
'use client';
import { useEffect } from 'react';
import logger from '@/utils/logger';
export default function ServiceWorkerRegister() {
useEffect(() => {
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js')
.catch((err) => logger.error('Service worker registration failed:', err));
}
}, []);
return null;
}

View File

@ -34,7 +34,7 @@ function Sidebar({ currentPage, items, onCloseMobile }) {
return ( return (
<div className="w-64 bg-stone-50 border-r h-full border-gray-200"> <div className="w-64 bg-stone-50 border-r h-full border-gray-200">
<div className="border-b border-gray-200 "> <div className="border-b border-gray-200 hidden md:block">
<ProfileSelector className="border-none h-24" /> <ProfileSelector className="border-none h-24" />
</div> </div>
<nav className="space-y-1 px-4 py-6"> <nav className="space-y-1 px-4 py-6">

View File

@ -1,8 +1,12 @@
import React, { useState } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ChevronLeft, ChevronRight } from 'lucide-react';
const SidebarTabs = ({ tabs, onTabChange }) => { const SidebarTabs = ({ tabs, onTabChange }) => {
const [activeTab, setActiveTab] = useState(tabs[0].id); const [activeTab, setActiveTab] = useState(tabs[0].id);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false);
const scrollRef = useRef(null);
const handleTabChange = (tabId) => { const handleTabChange = (tabId) => {
setActiveTab(tabId); setActiveTab(tabId);
@ -11,25 +15,79 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
} }
}; };
const updateArrows = () => {
const el = scrollRef.current;
if (!el) return;
setShowLeftArrow(el.scrollLeft > 0);
setShowRightArrow(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
useEffect(() => {
updateArrows();
const el = scrollRef.current;
if (!el) return;
el.addEventListener('scroll', updateArrows);
window.addEventListener('resize', updateArrows);
return () => {
el.removeEventListener('scroll', updateArrows);
window.removeEventListener('resize', updateArrows);
};
}, [tabs]);
const scroll = (direction) => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({ left: direction === 'left' ? -150 : 150, behavior: 'smooth' });
};
return ( return (
<div className="flex flex-col h-full w-full"> <div className="flex flex-col h-full w-full">
{/* Tabs Header */} {/* Tabs Header */}
<div className="flex h-14 bg-gray-50 border-b border-gray-200 shadow-sm"> <div className="relative flex items-center bg-gray-50 border-b border-gray-200 shadow-sm">
{/* Flèche gauche */}
{showLeftArrow && (
<button
onClick={() => scroll('left')}
className="absolute left-0 z-10 h-full w-10 flex items-center justify-center bg-gradient-to-r from-gray-50 via-gray-50 to-transparent text-gray-500 hover:text-emerald-600 active:text-emerald-700"
aria-label="Tabs précédents"
>
<ChevronLeft size={22} strokeWidth={2.5} />
</button>
)}
{/* Liste des onglets scrollable */}
<div
ref={scrollRef}
className="flex overflow-x-auto scrollbar-none scroll-smooth"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
className={`flex-1 text-center p-4 font-medium transition-colors duration-200 ${ onClick={() => handleTabChange(tab.id)}
className={`flex-shrink-0 whitespace-nowrap h-14 px-5 font-medium transition-colors duration-200 ${
activeTab === tab.id activeTab === tab.id
? 'border-b-4 border-emerald-500 text-emerald-600 bg-emerald-50 font-semibold' ? 'border-b-4 border-emerald-500 text-emerald-600 bg-emerald-50 font-semibold'
: 'text-gray-500 hover:text-emerald-500' : 'text-gray-500 hover:text-emerald-500'
}`} }`}
onClick={() => handleTabChange(tab.id)}
> >
{tab.label} {tab.label}
</button> </button>
))} ))}
</div> </div>
{/* Flèche droite */}
{showRightArrow && (
<button
onClick={() => scroll('right')}
className="absolute right-0 z-10 h-full w-10 flex items-center justify-center bg-gradient-to-l from-gray-50 via-gray-50 to-transparent text-gray-500 hover:text-emerald-600 active:text-emerald-700"
aria-label="Tabs suivants"
>
<ChevronRight size={22} strokeWidth={2.5} />
</button>
)}
</div>
{/* Tabs Content */} {/* Tabs Content */}
<div className="flex-1 flex flex-col overflow-hidden rounded-b-lg shadow-inner"> <div className="flex-1 flex flex-col overflow-hidden rounded-b-lg shadow-inner">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
@ -38,10 +96,10 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
activeTab === tab.id && ( activeTab === tab.id && (
<motion.div <motion.div
key={tab.id} key={tab.id}
initial={{ opacity: 0, x: 50 }} // Animation d'entrée initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }} // Animation visible animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }} // Animation de sortie exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.3 }} // Durée des animations transition={{ duration: 0.3 }}
className="flex-1 flex flex-col h-full min-h-0" className="flex-1 flex flex-col h-full min-h-0"
> >
{tab.content} {tab.content}

View File

@ -4,7 +4,7 @@ import React, {
forwardRef, forwardRef,
useImperativeHandle, useImperativeHandle,
} from 'react'; } from 'react';
import { CheckCircle, Circle } from 'lucide-react'; import { CheckCircle, Circle, ChevronDown, ChevronRight } from 'lucide-react';
import { useEstablishment } from '@/context/EstablishmentContext'; import { useEstablishment } from '@/context/EstablishmentContext';
const TreeView = forwardRef(function TreeView( const TreeView = forwardRef(function TreeView(
@ -80,20 +80,27 @@ const TreeView = forwardRef(function TreeView(
{data.map((domaine) => ( {data.map((domaine) => (
<div key={domaine.domaine_id} className="mb-4"> <div key={domaine.domaine_id} className="mb-4">
<button <button
className="w-full text-left px-3 py-2 bg-emerald-100 hover:bg-emerald-200 rounded font-semibold text-emerald-800" className="w-full text-left px-3 py-2 bg-emerald-100 hover:bg-emerald-200 rounded font-semibold text-emerald-800 flex items-center gap-2"
onClick={() => toggleDomain(domaine.domaine_id)} onClick={() => toggleDomain(domaine.domaine_id)}
> >
{openDomains[domaine.domaine_id] ? '▼' : '►'} {domaine.domaine_nom} {openDomains[domaine.domaine_id]
? <ChevronDown className="w-4 h-4 flex-shrink-0" />
: <ChevronRight className="w-4 h-4 flex-shrink-0" />
}
{domaine.domaine_nom}
</button> </button>
{openDomains[domaine.domaine_id] && ( {openDomains[domaine.domaine_id] && (
<div className="ml-4"> <div className="ml-4">
{domaine.categories.map((categorie) => ( {domaine.categories.map((categorie) => (
<div key={categorie.categorie_id} className="mb-2"> <div key={categorie.categorie_id} className="mb-2">
<button <button
className="w-full text-left px-2 py-1 bg-emerald-50 hover:bg-emerald-100 rounded text-emerald-700" className="w-full text-left px-2 py-1 bg-emerald-50 hover:bg-emerald-100 rounded text-emerald-700 flex items-center gap-2"
onClick={() => toggleCategory(categorie.categorie_id)} onClick={() => toggleCategory(categorie.categorie_id)}
> >
{openCategories[categorie.categorie_id] ? '▼' : '►'} {openCategories[categorie.categorie_id]
? <ChevronDown className="w-4 h-4 flex-shrink-0" />
: <ChevronRight className="w-4 h-4 flex-shrink-0" />
}
{categorie.categorie_nom} {categorie.categorie_nom}
</button> </button>
{openCategories[categorie.categorie_id] && ( {openCategories[categorie.categorie_id] && (

View File

@ -130,6 +130,12 @@ const ClassesSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const ITEMS_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => { setCurrentPage(1); }, [classes]);
useEffect(() => { if (newClass) setCurrentPage(1); }, [newClass]);
const totalPages = Math.ceil(classes.length / ITEMS_PER_PAGE);
const pagedClasses = classes.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const { selectedEstablishmentId, profileRole } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
const { addSchedule, reloadPlanning, reloadEvents } = usePlanning(); const { addSchedule, reloadPlanning, reloadEvents } = usePlanning();
const { getNiveauxLabels, allNiveaux } = useClasses(); const { getNiveauxLabels, allNiveaux } = useClasses();
@ -555,7 +561,7 @@ const ClassesSection = ({
onClick={handleAddClass} onClick={handleAddClass}
/> />
<Table <Table
data={newClass ? [newClass, ...classes] : classes} data={newClass ? [newClass, ...pagedClasses] : pagedClasses}
columns={columns} columns={columns}
renderCell={renderClassCell} renderCell={renderClassCell}
emptyMessage={ emptyMessage={
@ -565,6 +571,10 @@ const ClassesSection = ({
message="Veuillez procéder à la création d'une nouvelle classe." message="Veuillez procéder à la création d'une nouvelle classe."
/> />
} }
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/> />
<Popup <Popup
isOpen={popupVisible} isOpen={popupVisible}

View File

@ -1,5 +1,5 @@
import { Trash2, Edit3, Check, X, BookOpen } from 'lucide-react'; import { Trash2, Edit3, Check, X, BookOpen } from 'lucide-react';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon'; import InputTextWithColorIcon from '@/components/Form/InputTextWithColorIcon';
@ -28,6 +28,12 @@ const SpecialitiesSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const ITEMS_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => { setCurrentPage(1); }, [specialities]);
useEffect(() => { if (newSpeciality) setCurrentPage(1); }, [newSpeciality]);
const totalPages = Math.ceil(specialities.length / ITEMS_PER_PAGE);
const pagedSpecialities = specialities.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const { selectedEstablishmentId, profileRole } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
@ -253,7 +259,7 @@ const SpecialitiesSection = ({
onClick={handleAddSpeciality} onClick={handleAddSpeciality}
/> />
<Table <Table
data={newSpeciality ? [newSpeciality, ...specialities] : specialities} data={newSpeciality ? [newSpeciality, ...pagedSpecialities] : pagedSpecialities}
columns={columns} columns={columns}
renderCell={renderSpecialityCell} renderCell={renderSpecialityCell}
emptyMessage={ emptyMessage={
@ -263,6 +269,10 @@ const SpecialitiesSection = ({
message="Veuillez procéder à la création d'une nouvelle spécialité." message="Veuillez procéder à la création d'une nouvelle spécialité."
/> />
} }
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/> />
<Popup <Popup
isOpen={popupVisible} isOpen={popupVisible}

View File

@ -24,7 +24,9 @@ const StructureManagement = ({
return ( return (
<div className="w-full"> <div className="w-full">
<ClassesProvider> <ClassesProvider>
<div className="mt-8 w-2/5"> {/* Spécialités + Enseignants : côte à côte sur desktop, empilés sur mobile */}
<div className="mt-8 flex flex-col xl:flex-row gap-8">
<div className="w-full xl:w-2/5">
<SpecialitiesSection <SpecialitiesSection
specialities={specialities} specialities={specialities}
setSpecialities={setSpecialities} setSpecialities={setSpecialities}
@ -48,7 +50,7 @@ const StructureManagement = ({
} }
/> />
</div> </div>
<div className="w-4/5 mt-12"> <div className="w-full xl:flex-1">
<TeachersSection <TeachersSection
teachers={teachers} teachers={teachers}
setTeachers={setTeachers} setTeachers={setTeachers}
@ -70,7 +72,8 @@ const StructureManagement = ({
} }
/> />
</div> </div>
<div className="w-full mt-12"> </div>
<div className="w-full mt-8 xl:mt-12">
<ClassesSection <ClassesSection
classes={classes} classes={classes}
setClasses={setClasses} setClasses={setClasses}

View File

@ -137,6 +137,12 @@ const TeachersSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const ITEMS_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => { setCurrentPage(1); }, [teachers]);
useEffect(() => { if (newTeacher) setCurrentPage(1); }, [newTeacher]);
const totalPages = Math.ceil(teachers.length / ITEMS_PER_PAGE);
const pagedTeachers = teachers.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const { selectedEstablishmentId, profileRole } = useEstablishment(); const { selectedEstablishmentId, profileRole } = useEstablishment();
@ -535,7 +541,7 @@ const TeachersSection = ({
onClick={handleAddTeacher} onClick={handleAddTeacher}
/> />
<Table <Table
data={newTeacher ? [newTeacher, ...teachers] : teachers} data={newTeacher ? [newTeacher, ...pagedTeachers] : pagedTeachers}
columns={columns} columns={columns}
renderCell={renderTeacherCell} renderCell={renderTeacherCell}
emptyMessage={ emptyMessage={
@ -545,6 +551,10 @@ const TeachersSection = ({
message="Veuillez procéder à la création d'un nouvel enseignant." message="Veuillez procéder à la création d'un nouvel enseignant."
/> />
} }
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/> />
<Popup <Popup
isOpen={popupVisible} isOpen={popupVisible}

View File

@ -812,9 +812,9 @@ export default function FilesGroupsManagement({
<div className="mb-8">{renderExplanation()}</div> <div className="mb-8">{renderExplanation()}</div>
{/* 2 colonnes : groupes à gauche, documents à droite */} {/* 2 colonnes : groupes à gauche, documents à droite */}
<div className="flex flex-row gap-8"> <div className="flex flex-col xl:flex-row gap-8">
{/* Colonne groupes (1/3) */} {/* Colonne groupes (plein écran mobile/tablette, 1/3 desktop) */}
<div className="flex flex-col w-1/3 min-w-[320px] max-w-md"> <div className="flex flex-col w-full xl:w-1/3 xl:min-w-[320px] xl:max-w-md">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
<SectionTitle title="Liste des dossiers d'inscriptions" /> <SectionTitle title="Liste des dossiers d'inscriptions" />
<div className="flex-1" /> <div className="flex-1" />
@ -862,8 +862,8 @@ export default function FilesGroupsManagement({
/> />
</div> </div>
{/* Colonne documents (2/3) */} {/* Colonne documents (plein écran mobile/tablette, 2/3 desktop) */}
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-full xl:flex-1">
<div className="flex items-center mb-4"> <div className="flex items-center mb-4">
<SectionTitle title="Liste des documents" /> <SectionTitle title="Liste des documents" />
<div className="flex-1" /> <div className="flex-1" />

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Plus, Edit3, Trash2, Check, X, FileText } from 'lucide-react'; import { Plus, Edit3, Trash2, Check, X, FileText } from 'lucide-react';
import logger from '@/utils/logger'; import logger from '@/utils/logger';
import { createRegistrationParentFileTemplate } from '@/app/actions/registerFileGroupAction'; import { createRegistrationParentFileTemplate } from '@/app/actions/registerFileGroupAction';
@ -26,6 +26,11 @@ export default function ParentFilesSection({
const [selectedGroups, setSelectedGroups] = useState([]); // Gestion des groupes sélectionnés const [selectedGroups, setSelectedGroups] = useState([]); // Gestion des groupes sélectionnés
const [guardianDetails, setGuardianDetails] = useState([]); const [guardianDetails, setGuardianDetails] = useState([]);
const ITEMS_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => { setCurrentPage(1); }, [parentFiles]);
const totalPages = Math.ceil(parentFiles.length / ITEMS_PER_PAGE);
const pagedParentFiles = parentFiles.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
@ -347,10 +352,14 @@ export default function ParentFilesSection({
/> />
<Table <Table
data={ data={
editingDocumentId === 'new' ? [formData, ...parentFiles] : parentFiles editingDocumentId === 'new' ? [formData, ...pagedParentFiles] : pagedParentFiles
} }
columns={columnsRequiredDocuments} columns={columnsRequiredDocuments}
emptyMessage="Aucune pièce à fournir enregistrée" emptyMessage="Aucune pièce à fournir enregistrée"
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/> />
<Popup <Popup
isOpen={removePopupVisible} isOpen={removePopupVisible}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Trash2, Edit3, Check, X, Percent, EuroIcon, Tag } from 'lucide-react'; import { Trash2, Edit3, Check, X, Percent, EuroIcon, Tag } from 'lucide-react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
@ -32,6 +32,12 @@ const DiscountsSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const ITEMS_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => { setCurrentPage(1); }, [discounts]);
useEffect(() => { if (newDiscount) setCurrentPage(1); }, [newDiscount]);
const totalPages = Math.ceil(discounts.length / ITEMS_PER_PAGE);
const pagedDiscounts = discounts.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
const { selectedEstablishmentId } = useEstablishment(); const { selectedEstablishmentId } = useEstablishment();
@ -398,11 +404,15 @@ const DiscountsSection = ({
/> />
)} )}
<Table <Table
data={newDiscount ? [newDiscount, ...discounts] : discounts} data={newDiscount ? [newDiscount, ...pagedDiscounts] : pagedDiscounts}
columns={columns} columns={columns}
renderCell={renderDiscountCell} renderCell={renderDiscountCell}
defaultTheme="bg-yellow-50" defaultTheme="bg-yellow-50"
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/> />
<Popup <Popup
isOpen={popupVisible} isOpen={popupVisible}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Trash2, Edit3, Check, X, EyeOff, Eye, CreditCard } from 'lucide-react'; import { Trash2, Edit3, Check, X, EyeOff, Eye, CreditCard } from 'lucide-react';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Popup from '@/components/Popup'; import Popup from '@/components/Popup';
@ -37,6 +37,12 @@ const FeesSection = ({
const [removePopupVisible, setRemovePopupVisible] = useState(false); const [removePopupVisible, setRemovePopupVisible] = useState(false);
const [removePopupMessage, setRemovePopupMessage] = useState(''); const [removePopupMessage, setRemovePopupMessage] = useState('');
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {}); const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
const ITEMS_PER_PAGE = 10;
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => { setCurrentPage(1); }, [fees]);
useEffect(() => { if (newFee) setCurrentPage(1); }, [newFee]);
const totalPages = Math.ceil(fees.length / ITEMS_PER_PAGE);
const pagedFees = fees.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
// En mode unifié, le type effectif est celui du frais ou celui du formulaire de création // En mode unifié, le type effectif est celui du frais ou celui du formulaire de création
const labelTypeFrais = (feeType) => const labelTypeFrais = (feeType) =>
feeType === 0 ? "Frais d'inscription" : 'Frais de scolarité'; feeType === 0 ? "Frais d'inscription" : 'Frais de scolarité';
@ -372,10 +378,14 @@ const FeesSection = ({
/> />
)} )}
<Table <Table
data={newFee ? [newFee, ...fees] : fees} data={newFee ? [newFee, ...pagedFees] : pagedFees}
columns={columns} columns={columns}
renderCell={renderFeeCell} renderCell={renderFeeCell}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/> />
<Popup <Popup
isOpen={popupVisible} isOpen={popupVisible}

View File

@ -7,9 +7,9 @@ const Table = ({
columns, columns,
renderCell, renderCell,
itemsPerPage = 0, itemsPerPage = 0,
currentPage, currentPage = 1,
totalPages, totalPages = 1,
onPageChange, onPageChange = () => {},
onRowClick, onRowClick,
selectedRows, selectedRows,
isSelectable = false, isSelectable = false,
@ -21,9 +21,9 @@ const Table = ({
}; };
return ( return (
<div className="bg-stone-50 rounded-lg border border-gray-300 shadow-md"> <div className="md:bg-stone-50 md:rounded-lg md:border md:border-gray-300 md:shadow-md">
<table className="min-w-full bg-stone-50"> <table className="responsive-table min-w-full bg-stone-50">
<thead> <thead className="uppercase">
<tr> <tr>
{columns.map((column, index) => ( {columns.map((column, index) => (
<th <th
@ -64,6 +64,7 @@ const Table = ({
{columns.map((column, colIndex) => ( {columns.map((column, colIndex) => (
<td <td
key={colIndex} key={colIndex}
data-label={column.name}
className={`py-2 px-4 border-b border-gray-300 text-center text-sm ${ className={`py-2 px-4 border-b border-gray-300 text-center text-sm ${
selectedRows?.includes(row.id) selectedRows?.includes(row.id)
? 'text-white' ? 'text-white'
@ -84,7 +85,7 @@ const Table = ({
)} )}
</tbody> </tbody>
</table> </table>
{itemsPerPage > 0 && data && data.length > 0 && ( {itemsPerPage > 0 && totalPages > 1 && data && data.length > 0 && (
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
@ -105,9 +106,9 @@ Table.propTypes = {
).isRequired, ).isRequired,
renderCell: PropTypes.func, renderCell: PropTypes.func,
itemsPerPage: PropTypes.number, itemsPerPage: PropTypes.number,
currentPage: PropTypes.number.isRequired, currentPage: PropTypes.number,
totalPages: PropTypes.number.isRequired, totalPages: PropTypes.number,
onPageChange: PropTypes.func.isRequired, onPageChange: PropTypes.func,
onRowClick: PropTypes.func, onRowClick: PropTypes.func,
selectedRows: PropTypes.arrayOf(PropTypes.any), selectedRows: PropTypes.arrayOf(PropTypes.any),
isSelectable: PropTypes.bool, isSelectable: PropTypes.bool,

View File

@ -14,7 +14,7 @@ const Tooltip = ({ content, children }) => {
{children} {children}
</div> </div>
{visible && ( {visible && (
<div className="absolute z-10 w-64 p-2 bg-white border border-gray-200 rounded shadow-lg"> <div className="absolute z-10 w-max max-w-[min(16rem,calc(100vw-2rem))] p-2 bg-white border border-gray-200 rounded shadow-lg">
{content} {content}
</div> </div>
)} )}

View File

@ -88,3 +88,62 @@
opacity: 0; opacity: 0;
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
} }
/* Masquer la scrollbar sur les conteneurs de navigation par onglets */
.scrollbar-none::-webkit-scrollbar {
display: none;
}
/* =============================================
Responsive table — mode "stacked" sur mobile
Sur md+ : table classique
Sous md : chaque ligne → carte verticale,
chaque cellule affiche son label
============================================= */
@media (max-width: 767px) {
.responsive-table thead {
display: none;
}
.responsive-table {
background: transparent;
}
.responsive-table tbody {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0;
background: transparent;
}
.responsive-table tbody tr {
display: block;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background-color: #fafaf9; /* stone-50 */
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.07);
padding: 0.25rem 0;
overflow: hidden;
}
.responsive-table tbody td {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 1rem;
text-align: right;
font-size: 0.875rem;
}
/* Label = nom de la colonne injecté via data-label */
.responsive-table tbody td::before {
content: attr(data-label);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280; /* gray-500 */
text-align: left;
margin-right: 1rem;
flex-shrink: 0;
}
}

View File

@ -15,13 +15,28 @@ const options = {
authorize: async (credentials) => { authorize: async (credentials) => {
// URL calculée ici (pas au niveau module) pour garantir que NEXT_PUBLIC_API_URL est chargé // URL calculée ici (pas au niveau module) pour garantir que NEXT_PUBLIC_API_URL est chargé
const loginUrl = `${process.env.NEXT_PUBLIC_API_URL}/Auth/login`; const loginUrl = `${process.env.NEXT_PUBLIC_API_URL}/Auth/login`;
const csrfUrl = `${process.env.NEXT_PUBLIC_API_URL}/Auth/csrf`;
try { try {
// Récupération server-side du CSRF token + cookie Django
const csrfRes = await fetch(csrfUrl, {
method: 'GET',
headers: { Connection: 'close' },
});
const csrfData = await csrfRes.json();
const csrfToken = csrfData?.csrfToken;
// Extraction du cookie csrftoken depuis la réponse
const rawCookies = csrfRes.headers.get('set-cookie') || '';
const csrfCookieMatch = rawCookies.match(/csrftoken=([^;]+)/);
const csrfCookie = csrfCookieMatch ? csrfCookieMatch[1] : csrfToken;
const res = await fetch(loginUrl, { const res = await fetch(loginUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
// Connection: close évite le SocketError undici lié au keep-alive vers Daphne // Connection: close évite le SocketError undici lié au keep-alive vers Daphne
Connection: 'close', Connection: 'close',
'X-CSRFToken': csrfToken,
Cookie: `csrftoken=${csrfCookie}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
email: credentials.email, email: credentials.email,