mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-29 07:53:23 +00:00
chore: câblage des absences/retard dans le suivi pédagogique
This commit is contained in:
@ -418,7 +418,7 @@ class AbsenceReason(models.IntegerChoices):
|
|||||||
UNJUSTIFIED_LATE = 4, 'Unjustified Late'
|
UNJUSTIFIED_LATE = 4, 'Unjustified Late'
|
||||||
|
|
||||||
class AbsenceManagement(models.Model):
|
class AbsenceManagement(models.Model):
|
||||||
day = models.DateField()
|
day = models.DateField(blank=True, null=True)
|
||||||
moment = models.IntegerField(
|
moment = models.IntegerField(
|
||||||
choices=AbsenceMoment.choices,
|
choices=AbsenceMoment.choices,
|
||||||
default=AbsenceMoment.TOTAL
|
default=AbsenceMoment.TOTAL
|
||||||
@ -430,9 +430,11 @@ class AbsenceManagement(models.Model):
|
|||||||
student = models.ForeignKey(
|
student = models.ForeignKey(
|
||||||
Student,
|
Student,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='absences'
|
related_name='absences',
|
||||||
|
blank=True, null=True
|
||||||
)
|
)
|
||||||
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='absences')
|
establishment = models.ForeignKey('Establishment.Establishment', on_delete=models.CASCADE, related_name='absences', blank=True, null=True)
|
||||||
|
commentaire = models.TextField(blank=True, null=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.student} - {self.day} - {self.get_moment_display()} - {self.get_reason_display()}"
|
return f"{self.student} - {self.day} - {self.get_moment_display()} - {self.get_reason_display()}"
|
||||||
@ -20,6 +20,9 @@ import {
|
|||||||
fetchStudents,
|
fetchStudents,
|
||||||
fetchStudentCompetencies,
|
fetchStudentCompetencies,
|
||||||
searchStudents,
|
searchStudents,
|
||||||
|
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';
|
||||||
@ -28,9 +31,11 @@ import SectionHeader from '@/components/SectionHeader';
|
|||||||
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
||||||
import InputText from '@/components/InputText';
|
import InputText from '@/components/InputText';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const csrfToken = useCsrfToken();
|
||||||
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
const { selectedEstablishmentId, selectedEstablishmentEvaluationFrequency } =
|
||||||
useEstablishment();
|
useEstablishment();
|
||||||
const { getNiveauLabel } = useClasses();
|
const { getNiveauLabel } = useClasses();
|
||||||
@ -43,6 +48,7 @@ export default function Page() {
|
|||||||
const [grades, setGrades] = useState({});
|
const [grades, setGrades] = useState({});
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
const [selectedPeriod, setSelectedPeriod] = useState(null);
|
||||||
|
const [allAbsences, setAllAbsences] = useState([]);
|
||||||
|
|
||||||
// Définir les périodes selon la fréquence
|
// Définir les périodes selon la fréquence
|
||||||
const getPeriods = () => {
|
const getPeriods = () => {
|
||||||
@ -99,11 +105,6 @@ export default function Page() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const absences = [
|
|
||||||
{ date: '2023-09-01', type: 'Absence', reason: 'Maladie', justified: true },
|
|
||||||
{ date: '2023-09-15', type: 'Retard', reason: 'Trafic', justified: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
const remarks = [
|
const remarks = [
|
||||||
{
|
{
|
||||||
date: '2023-09-10',
|
date: '2023-09-10',
|
||||||
@ -196,6 +197,32 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
}, [formData.selectedStudent, selectedPeriod]);
|
}, [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
|
// Fonction utilitaire pour convertir la période sélectionnée en string backend
|
||||||
function getPeriodString(selectedPeriod, frequency) {
|
function getPeriodString(selectedPeriod, frequency) {
|
||||||
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; // année scolaire commence en septembre
|
const year = dayjs().month() >= 8 ? dayjs().year() : dayjs().year() - 1; // année scolaire commence en septembre
|
||||||
@ -207,6 +234,42 @@ export default function Page() {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Callback pour justifier/non justifier une absence
|
||||||
|
const handleToggleJustify = (absence) => {
|
||||||
|
// Inverser l'état justifié (1/3 = justifié, 2/4 = non justifié)
|
||||||
|
const newReason =
|
||||||
|
absence.type === 'Absence'
|
||||||
|
? absence.justified
|
||||||
|
? 2 // Absence non justifiée
|
||||||
|
: 1 // Absence justifiée
|
||||||
|
: absence.justified
|
||||||
|
? 4 // Retard non justifié
|
||||||
|
: 3; // Retard justifié
|
||||||
|
|
||||||
|
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
|
||||||
|
.then(() => {
|
||||||
|
setAllAbsences((prev) =>
|
||||||
|
prev.map((a) =>
|
||||||
|
a.id === absence.id ? { ...a, reason: newReason } : a
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
logger.error('Erreur lors du changement de justification', e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Callback pour supprimer une absence
|
||||||
|
const handleDeleteAbsence = (absence) => {
|
||||||
|
return deleteAbsences(absence.id, csrfToken)
|
||||||
|
.then(() => {
|
||||||
|
setAllAbsences((prev) => prev.filter((a) => a.id !== absence.id));
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
logger.error("Erreur lors de la suppression de l'absence", e);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-8 space-y-8">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
@ -392,7 +455,11 @@ export default function Page() {
|
|||||||
<div className="flex flex-col gap-8 w-full justify-center items-stretch">
|
<div className="flex flex-col gap-8 w-full justify-center items-stretch">
|
||||||
<div className="w-full flex flex-row items-stretch gap-4">
|
<div className="w-full flex flex-row items-stretch gap-4">
|
||||||
<div className="flex-1 flex items-stretch justify-center h-full">
|
<div className="flex-1 flex items-stretch justify-center h-full">
|
||||||
<Attendance absences={absences} />
|
<Attendance
|
||||||
|
absences={absences}
|
||||||
|
onToggleJustify={handleToggleJustify}
|
||||||
|
onDelete={handleDeleteAbsence}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-stretch justify-center h-full">
|
<div className="flex-1 flex items-stretch justify-center h-full">
|
||||||
<GradesStatsCircle grades={grades} />
|
<GradesStatsCircle grades={grades} />
|
||||||
|
|||||||
@ -345,6 +345,7 @@ export default function Page() {
|
|||||||
reason: reason,
|
reason: reason,
|
||||||
moment: absenceData.moment,
|
moment: absenceData.moment,
|
||||||
establishment: selectedEstablishmentId,
|
establishment: selectedEstablishmentId,
|
||||||
|
commentaire: absenceData.commentaire,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (absenceData.id) {
|
if (absenceData.id) {
|
||||||
@ -574,7 +575,7 @@ export default function Page() {
|
|||||||
Motif d'absence
|
Motif d'absence
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 items-center">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 items-center">
|
||||||
{/* Select Absence/Retard */}
|
{/* Select Absence/Retard */}
|
||||||
<SelectChoice
|
<SelectChoice
|
||||||
name={`type-${row.id}`}
|
name={`type-${row.id}`}
|
||||||
@ -619,6 +620,23 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Nouveau champ commentaire */}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="border rounded px-2 py-1 text-sm w-full"
|
||||||
|
placeholder="Commentaire"
|
||||||
|
value={formAbsences[row.id]?.commentaire || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormAbsences((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[row.id]: {
|
||||||
|
...prev[row.id],
|
||||||
|
commentaire: e.target.value,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Checkbox Justifié */}
|
{/* Checkbox Justifié */}
|
||||||
<div className="flex items-center gap-2 justify-center">
|
<div className="flex items-center gap-2 justify-center">
|
||||||
<CheckBox
|
<CheckBox
|
||||||
|
|||||||
@ -1,13 +1,34 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
import ToggleSwitch from '@/components/ToggleSwitch';
|
||||||
|
import Button from '@/components/Button';
|
||||||
|
import Popup from '@/components/Popup';
|
||||||
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
|
|
||||||
|
export default function Attendance({ absences, onToggleJustify, onDelete }) {
|
||||||
|
const [popupVisible, setPopupVisible] = useState(false);
|
||||||
|
const [popupMessage, setPopupMessage] = useState('');
|
||||||
|
const [removePopupVisible, setRemovePopupVisible] = useState(false);
|
||||||
|
const [removePopupMessage, setRemovePopupMessage] = useState('');
|
||||||
|
const [removePopupOnConfirm, setRemovePopupOnConfirm] = useState(() => {});
|
||||||
|
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
export default function Attendance({ absences }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-200">
|
<div className="w-full bg-stone-50 p-6 rounded-lg shadow-sm border border-gray-200">
|
||||||
<h2 className="text-xl font-semibold mb-4">Présence et assiduité</h2>
|
<h2 className="text-xl font-semibold mb-4">Présence et assiduité</h2>
|
||||||
|
{absences.length === 0 ? (
|
||||||
|
<div className="text-center text-emerald-600 font-medium py-8">
|
||||||
|
Aucune absence enregistrée 🎉
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<ol className="relative border-l border-emerald-200">
|
<ol className="relative border-l border-emerald-200">
|
||||||
{absences.map((absence, idx) => (
|
{absences.map((absence, idx) => (
|
||||||
<li key={idx} className="mb-6 ml-4">
|
<li key={idx} className="mb-6 ml-4">
|
||||||
<div className="absolute w-3 h-3 bg-emerald-400 rounded-full mt-1.5 -left-1.5 border border-white" />
|
<div className="absolute w-3 h-3 bg-emerald-400 rounded-full mt-1.5 -left-1.5 border border-white" />
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
{/* Infos principales à gauche */}
|
||||||
|
<div className="flex flex-col">
|
||||||
<time className="mb-1 text-xs font-normal leading-none text-gray-400">
|
<time className="mb-1 text-xs font-normal leading-none text-gray-400">
|
||||||
{absence.date}
|
{absence.date}
|
||||||
</time>
|
</time>
|
||||||
@ -19,10 +40,64 @@ export default function Attendance({ absences }) {
|
|||||||
{absence.justified ? 'Justifiée' : 'Non justifiée'}
|
{absence.justified ? 'Justifiée' : 'Non justifiée'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">{absence.reason}</div>
|
<div className="text-sm text-gray-500">
|
||||||
|
{absence.commentaire}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Actions à droite */}
|
||||||
|
<div className="flex flex-col items-end gap-2 min-w-[110px]">
|
||||||
|
<ToggleSwitch
|
||||||
|
name={`justified-${idx}`}
|
||||||
|
checked={absence.justified}
|
||||||
|
onChange={() => onToggleJustify(absence)}
|
||||||
|
label="Justifiée"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
primary
|
||||||
|
onClick={() => {
|
||||||
|
setRemovePopupVisible(true);
|
||||||
|
setRemovePopupMessage(
|
||||||
|
"Attention ! \nVous êtes sur le point de supprimer l'absence enregistrée.\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?"
|
||||||
|
);
|
||||||
|
setRemovePopupOnConfirm(() => () => {
|
||||||
|
onDelete(absence)
|
||||||
|
.then((data) => {
|
||||||
|
showNotification(
|
||||||
|
'Opération effectuée avec succès.',
|
||||||
|
'success',
|
||||||
|
'Succès'
|
||||||
|
);
|
||||||
|
setPopupVisible(true);
|
||||||
|
setRemovePopupVisible(false);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
showNotification(
|
||||||
|
'Erreur lors de la suppression de l\absence',
|
||||||
|
'error',
|
||||||
|
'Erreur'
|
||||||
|
);
|
||||||
|
setPopupVisible(true);
|
||||||
|
setRemovePopupVisible(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="mb-1 px-4 py-2 rounded-md shadow bg-emerald-500 text-white hover:bg-emerald-600 w-full"
|
||||||
|
icon={<Trash2 className="w-6 h-6" />}
|
||||||
|
text="Supprimer"
|
||||||
|
title="Evaluez l'élève"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
|
)}
|
||||||
|
<Popup
|
||||||
|
isOpen={removePopupVisible}
|
||||||
|
message={removePopupMessage}
|
||||||
|
onConfirm={removePopupOnConfirm}
|
||||||
|
onCancel={() => setRemovePopupVisible(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user