mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-05 12:41:27 +00:00
feat: Securisation du téléchargement de fichier
This commit is contained in:
@ -3,6 +3,7 @@ from django.urls import path, re_path
|
|||||||
from .views import (
|
from .views import (
|
||||||
DomainListCreateView, DomainDetailView,
|
DomainListCreateView, DomainDetailView,
|
||||||
CategoryListCreateView, CategoryDetailView,
|
CategoryListCreateView, CategoryDetailView,
|
||||||
|
ServeFileView,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
@ -11,4 +12,6 @@ urlpatterns = [
|
|||||||
|
|
||||||
re_path(r'^categories$', CategoryListCreateView.as_view(), name="category_list_create"),
|
re_path(r'^categories$', CategoryListCreateView.as_view(), name="category_list_create"),
|
||||||
re_path(r'^categories/(?P<id>[0-9]+)$', CategoryDetailView.as_view(), name="category_detail"),
|
re_path(r'^categories/(?P<id>[0-9]+)$', CategoryDetailView.as_view(), name="category_detail"),
|
||||||
|
|
||||||
|
path('serve-file/', ServeFileView.as_view(), name="serve_file"),
|
||||||
]
|
]
|
||||||
@ -1,3 +1,8 @@
|
|||||||
|
import os
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import FileResponse
|
||||||
from django.http.response import JsonResponse
|
from django.http.response import JsonResponse
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
@ -117,3 +122,55 @@ class CategoryDetailView(APIView):
|
|||||||
return JsonResponse({'message': 'Deleted'}, safe=False)
|
return JsonResponse({'message': 'Deleted'}, safe=False)
|
||||||
except Category.DoesNotExist:
|
except Category.DoesNotExist:
|
||||||
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
return JsonResponse({'error': 'No object found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
class ServeFileView(APIView):
|
||||||
|
"""Sert les fichiers media de manière sécurisée avec authentification JWT."""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
file_path = request.query_params.get('path', '')
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return JsonResponse(
|
||||||
|
{'error': 'Le paramètre "path" est requis'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nettoyer le préfixe /data/ si présent
|
||||||
|
if file_path.startswith('/data/'):
|
||||||
|
file_path = file_path[len('/data/'):]
|
||||||
|
elif file_path.startswith('data/'):
|
||||||
|
file_path = file_path[len('data/'):]
|
||||||
|
|
||||||
|
# Construire le chemin absolu et le résoudre pour éliminer les traversals
|
||||||
|
absolute_path = os.path.realpath(
|
||||||
|
os.path.join(settings.MEDIA_ROOT, file_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Protection contre le path traversal
|
||||||
|
media_root = os.path.realpath(settings.MEDIA_ROOT)
|
||||||
|
if not absolute_path.startswith(media_root + os.sep) and absolute_path != media_root:
|
||||||
|
return JsonResponse(
|
||||||
|
{'error': 'Accès non autorisé'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.isfile(absolute_path):
|
||||||
|
return JsonResponse(
|
||||||
|
{'error': 'Fichier introuvable'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
content_type, _ = mimetypes.guess_type(absolute_path)
|
||||||
|
if content_type is None:
|
||||||
|
content_type = 'application/octet-stream'
|
||||||
|
|
||||||
|
response = FileResponse(
|
||||||
|
open(absolute_path, 'rb'),
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
response['Content-Disposition'] = (
|
||||||
|
f'inline; filename="{os.path.basename(absolute_path)}"'
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|||||||
@ -8,7 +8,8 @@ import GradesDomainBarChart from '@/components/Grades/GradesDomainBarChart';
|
|||||||
import { EvaluationStudentView } from '@/components/Evaluation';
|
import { EvaluationStudentView } from '@/components/Evaluation';
|
||||||
import Button from '@/components/Form/Button';
|
import Button from '@/components/Form/Button';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL, BASE_URL } from '@/utils/Url';
|
import { FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL } from '@/utils/Url';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import {
|
import {
|
||||||
fetchStudents,
|
fetchStudents,
|
||||||
fetchStudentCompetencies,
|
fetchStudentCompetencies,
|
||||||
@ -147,21 +148,33 @@ export default function StudentGradesPage() {
|
|||||||
|
|
||||||
// Load evaluations for the student
|
// Load evaluations for the student
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (student?.associated_class_id && selectedPeriod && selectedEstablishmentId) {
|
if (
|
||||||
|
student?.associated_class_id &&
|
||||||
|
selectedPeriod &&
|
||||||
|
selectedEstablishmentId
|
||||||
|
) {
|
||||||
const periodString = getPeriodString(
|
const periodString = getPeriodString(
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
selectedEstablishmentEvaluationFrequency
|
selectedEstablishmentEvaluationFrequency
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load evaluations for the class
|
// Load evaluations for the class
|
||||||
fetchEvaluations(selectedEstablishmentId, student.associated_class_id, periodString)
|
fetchEvaluations(
|
||||||
|
selectedEstablishmentId,
|
||||||
|
student.associated_class_id,
|
||||||
|
periodString
|
||||||
|
)
|
||||||
.then((data) => setEvaluations(data))
|
.then((data) => setEvaluations(data))
|
||||||
.catch((error) => logger.error('Erreur lors du fetch des évaluations:', error));
|
.catch((error) =>
|
||||||
|
logger.error('Erreur lors du fetch des évaluations:', error)
|
||||||
|
);
|
||||||
|
|
||||||
// Load student's evaluation scores
|
// Load student's evaluation scores
|
||||||
fetchStudentEvaluations(studentId, null, periodString, null)
|
fetchStudentEvaluations(studentId, null, periodString, null)
|
||||||
.then((data) => setStudentEvaluationsData(data))
|
.then((data) => setStudentEvaluationsData(data))
|
||||||
.catch((error) => logger.error('Erreur lors du fetch des notes:', error));
|
.catch((error) =>
|
||||||
|
logger.error('Erreur lors du fetch des notes:', error)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [student, selectedPeriod, selectedEstablishmentId]);
|
}, [student, selectedPeriod, selectedEstablishmentId]);
|
||||||
|
|
||||||
@ -182,8 +195,12 @@ export default function StudentGradesPage() {
|
|||||||
const handleToggleJustify = (absence) => {
|
const handleToggleJustify = (absence) => {
|
||||||
const newReason =
|
const newReason =
|
||||||
absence.type === 'Absence'
|
absence.type === 'Absence'
|
||||||
? absence.justified ? 2 : 1
|
? absence.justified
|
||||||
: absence.justified ? 4 : 3;
|
? 2
|
||||||
|
: 1
|
||||||
|
: absence.justified
|
||||||
|
? 4
|
||||||
|
: 3;
|
||||||
|
|
||||||
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
|
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@ -193,7 +210,9 @@ export default function StudentGradesPage() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((e) => logger.error('Erreur lors du changement de justification', e));
|
.catch((e) =>
|
||||||
|
logger.error('Erreur lors du changement de justification', e)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAbsence = (absence) => {
|
const handleDeleteAbsence = (absence) => {
|
||||||
@ -210,8 +229,16 @@ export default function StudentGradesPage() {
|
|||||||
try {
|
try {
|
||||||
await updateStudentEvaluation(studentEvalId, data, csrfToken);
|
await updateStudentEvaluation(studentEvalId, data, csrfToken);
|
||||||
// Reload student evaluations
|
// Reload student evaluations
|
||||||
const periodString = getPeriodString(selectedPeriod, selectedEstablishmentEvaluationFrequency);
|
const periodString = getPeriodString(
|
||||||
const updatedData = await fetchStudentEvaluations(studentId, null, periodString, null);
|
selectedPeriod,
|
||||||
|
selectedEstablishmentEvaluationFrequency
|
||||||
|
);
|
||||||
|
const updatedData = await fetchStudentEvaluations(
|
||||||
|
studentId,
|
||||||
|
null,
|
||||||
|
periodString,
|
||||||
|
null
|
||||||
|
);
|
||||||
setStudentEvaluationsData(updatedData);
|
setStudentEvaluationsData(updatedData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Erreur lors de la modification de la note:', error);
|
logger.error('Erreur lors de la modification de la note:', error);
|
||||||
@ -237,7 +264,7 @@ export default function StudentGradesPage() {
|
|||||||
<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">
|
<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 ? (
|
{student.photo ? (
|
||||||
<img
|
<img
|
||||||
src={`${BASE_URL}${student.photo}`}
|
src={getSecureFileUrl(student.photo)}
|
||||||
alt={`${student.first_name} ${student.last_name}`}
|
alt={`${student.first_name} ${student.last_name}`}
|
||||||
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow"
|
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,21 +1,34 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Award, Eye, Search, BarChart2, X, Pencil, Trash2, Save } from 'lucide-react';
|
import {
|
||||||
|
Award,
|
||||||
|
Eye,
|
||||||
|
Search,
|
||||||
|
BarChart2,
|
||||||
|
X,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
} from 'lucide-react';
|
||||||
import SectionHeader from '@/components/SectionHeader';
|
import SectionHeader from '@/components/SectionHeader';
|
||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import {
|
import {
|
||||||
BASE_URL,
|
|
||||||
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
|
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
|
||||||
FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL,
|
FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL,
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import {
|
import {
|
||||||
fetchStudents,
|
fetchStudents,
|
||||||
fetchStudentCompetencies,
|
fetchStudentCompetencies,
|
||||||
fetchAbsences,
|
fetchAbsences,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
import { fetchStudentEvaluations, updateStudentEvaluation, deleteStudentEvaluation } from '@/app/actions/schoolAction';
|
import {
|
||||||
|
fetchStudentEvaluations,
|
||||||
|
updateStudentEvaluation,
|
||||||
|
deleteStudentEvaluation,
|
||||||
|
} from '@/app/actions/schoolAction';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
@ -42,9 +55,17 @@ function calcCompetencyStats(data) {
|
|||||||
const total = scores.length;
|
const total = scores.length;
|
||||||
return {
|
return {
|
||||||
acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100),
|
acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100),
|
||||||
inProgress: Math.round((scores.filter((s) => s === 2).length / total) * 100),
|
inProgress: Math.round(
|
||||||
notAcquired: Math.round((scores.filter((s) => s === 1).length / total) * 100),
|
(scores.filter((s) => s === 2).length / total) * 100
|
||||||
notEvaluated: Math.round((scores.filter((s) => s === null || s === undefined || s === 0).length / total) * 100),
|
),
|
||||||
|
notAcquired: Math.round(
|
||||||
|
(scores.filter((s) => s === 1).length / total) * 100
|
||||||
|
),
|
||||||
|
notEvaluated: Math.round(
|
||||||
|
(scores.filter((s) => s === null || s === undefined || s === 0).length /
|
||||||
|
total) *
|
||||||
|
100
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,10 +86,26 @@ function getPeriodColumns(frequency) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COMPETENCY_COLUMNS = [
|
const COMPETENCY_COLUMNS = [
|
||||||
{ key: 'acquired', label: 'Acquises', color: 'bg-emerald-100 text-emerald-700' },
|
{
|
||||||
{ key: 'inProgress', label: 'En cours', color: 'bg-yellow-100 text-yellow-700' },
|
key: 'acquired',
|
||||||
{ key: 'notAcquired', label: 'Non acquises', color: 'bg-red-100 text-red-600' },
|
label: 'Acquises',
|
||||||
{ key: 'notEvaluated', label: 'Non évaluées', color: 'bg-gray-100 text-gray-600' },
|
color: 'bg-emerald-100 text-emerald-700',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inProgress',
|
||||||
|
label: 'En cours',
|
||||||
|
color: 'bg-yellow-100 text-yellow-700',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notAcquired',
|
||||||
|
label: 'Non acquises',
|
||||||
|
color: 'bg-red-100 text-red-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notEvaluated',
|
||||||
|
label: 'Non évaluées',
|
||||||
|
color: 'bg-gray-100 text-gray-600',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getCurrentPeriodValue(frequency) {
|
function getCurrentPeriodValue(frequency) {
|
||||||
@ -97,13 +134,13 @@ function getCurrentPeriodValue(frequency) {
|
|||||||
function PercentBadge({ value, loading, color }) {
|
function PercentBadge({ value, loading, color }) {
|
||||||
if (loading) return <span className="text-gray-300 text-xs">…</span>;
|
if (loading) return <span className="text-gray-300 text-xs">…</span>;
|
||||||
if (value === null) return <span className="text-gray-400 text-xs">—</span>;
|
if (value === null) return <span className="text-gray-400 text-xs">—</span>;
|
||||||
const badgeColor = color || (
|
const badgeColor =
|
||||||
value >= 75
|
color ||
|
||||||
|
(value >= 75
|
||||||
? 'bg-emerald-100 text-emerald-700'
|
? 'bg-emerald-100 text-emerald-700'
|
||||||
: value >= 50
|
: value >= 50
|
||||||
? 'bg-yellow-100 text-yellow-700'
|
? 'bg-yellow-100 text-yellow-700'
|
||||||
: 'bg-red-100 text-red-600'
|
: 'bg-red-100 text-red-600');
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${badgeColor}`}
|
className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold ${badgeColor}`}
|
||||||
@ -184,7 +221,9 @@ export default function Page() {
|
|||||||
if (data?.data) {
|
if (data?.data) {
|
||||||
data.data.forEach((d) =>
|
data.data.forEach((d) =>
|
||||||
d.categories.forEach((c) =>
|
d.categories.forEach((c) =>
|
||||||
c.competences.forEach((comp) => studentScores[studentId].push(comp.score))
|
c.competences.forEach((comp) =>
|
||||||
|
studentScores[studentId].push(comp.score)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -197,10 +236,21 @@ export default function Page() {
|
|||||||
} else {
|
} else {
|
||||||
const total = scores.length;
|
const total = scores.length;
|
||||||
map[studentId] = {
|
map[studentId] = {
|
||||||
acquired: Math.round((scores.filter((s) => s === 3).length / total) * 100),
|
acquired: Math.round(
|
||||||
inProgress: Math.round((scores.filter((s) => s === 2).length / total) * 100),
|
(scores.filter((s) => s === 3).length / total) * 100
|
||||||
notAcquired: Math.round((scores.filter((s) => s === 1).length / total) * 100),
|
),
|
||||||
notEvaluated: Math.round((scores.filter((s) => s === null || s === undefined || s === 0).length / total) * 100),
|
inProgress: Math.round(
|
||||||
|
(scores.filter((s) => s === 2).length / total) * 100
|
||||||
|
),
|
||||||
|
notAcquired: Math.round(
|
||||||
|
(scores.filter((s) => s === 1).length / total) * 100
|
||||||
|
),
|
||||||
|
notEvaluated: Math.round(
|
||||||
|
(scores.filter((s) => s === null || s === undefined || s === 0)
|
||||||
|
.length /
|
||||||
|
total) *
|
||||||
|
100
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -263,15 +313,31 @@ export default function Page() {
|
|||||||
|
|
||||||
const handleSaveEval = async (evalItem) => {
|
const handleSaveEval = async (evalItem) => {
|
||||||
try {
|
try {
|
||||||
await updateStudentEvaluation(evalItem.id, {
|
await updateStudentEvaluation(
|
||||||
score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)),
|
evalItem.id,
|
||||||
is_absent: editAbsent,
|
{
|
||||||
}, csrfToken);
|
score: editAbsent
|
||||||
|
? null
|
||||||
|
: editScore === ''
|
||||||
|
? null
|
||||||
|
: parseFloat(editScore),
|
||||||
|
is_absent: editAbsent,
|
||||||
|
},
|
||||||
|
csrfToken
|
||||||
|
);
|
||||||
// Update local state
|
// Update local state
|
||||||
setStudentEvaluations((prev) =>
|
setStudentEvaluations((prev) =>
|
||||||
prev.map((e) =>
|
prev.map((e) =>
|
||||||
e.id === evalItem.id
|
e.id === evalItem.id
|
||||||
? { ...e, score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)), is_absent: editAbsent }
|
? {
|
||||||
|
...e,
|
||||||
|
score: editAbsent
|
||||||
|
? null
|
||||||
|
: editScore === ''
|
||||||
|
? null
|
||||||
|
: parseFloat(editScore),
|
||||||
|
is_absent: editAbsent,
|
||||||
|
}
|
||||||
: e
|
: e
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@ -318,7 +384,10 @@ export default function Page() {
|
|||||||
{ name: 'Élève', transform: () => null },
|
{ name: 'Élève', transform: () => null },
|
||||||
{ name: 'Niveau', transform: () => null },
|
{ name: 'Niveau', transform: () => null },
|
||||||
{ name: 'Classe', transform: () => null },
|
{ name: 'Classe', transform: () => null },
|
||||||
...COMPETENCY_COLUMNS.map(({ label }) => ({ name: label, transform: () => null })),
|
...COMPETENCY_COLUMNS.map(({ label }) => ({
|
||||||
|
name: label,
|
||||||
|
transform: () => null,
|
||||||
|
})),
|
||||||
{ name: 'Absences', transform: () => null },
|
{ name: 'Absences', transform: () => null },
|
||||||
{ name: 'Actions', transform: () => null },
|
{ name: 'Actions', transform: () => null },
|
||||||
];
|
];
|
||||||
@ -331,13 +400,13 @@ export default function Page() {
|
|||||||
<div className="flex justify-center items-center">
|
<div className="flex justify-center items-center">
|
||||||
{student.photo ? (
|
{student.photo ? (
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${student.photo}`}
|
href={getSecureFileUrl(student.photo)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${BASE_URL}${student.photo}`}
|
src={getSecureFileUrl(student.photo)}
|
||||||
alt={`${student.first_name} ${student.last_name}`}
|
alt={`${student.first_name} ${student.last_name}`}
|
||||||
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
||||||
/>
|
/>
|
||||||
@ -345,7 +414,8 @@ export default function Page() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
|
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
|
||||||
<span className="text-gray-500 text-sm font-semibold">
|
<span className="text-gray-500 text-sm font-semibold">
|
||||||
{student.first_name?.[0]}{student.last_name?.[0]}
|
{student.first_name?.[0]}
|
||||||
|
{student.last_name?.[0]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -364,7 +434,9 @@ export default function Page() {
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
router.push(`${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${student.associated_class_id}`);
|
router.push(
|
||||||
|
`${FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL}?schoolClassId=${student.associated_class_id}`
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
className="text-emerald-700 hover:underline font-medium"
|
className="text-emerald-700 hover:underline font-medium"
|
||||||
>
|
>
|
||||||
@ -385,7 +457,10 @@ export default function Page() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); router.push(`/admin/grades/${student.id}`); }}
|
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"
|
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"
|
title="Voir la fiche"
|
||||||
>
|
>
|
||||||
@ -469,10 +544,12 @@ export default function Page() {
|
|||||||
<div className="flex items-center justify-between px-6 py-4 border-b bg-gray-50">
|
<div className="flex items-center justify-between px-6 py-4 border-b bg-gray-50">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-800">
|
<h2 className="text-lg font-semibold text-gray-800">
|
||||||
Notes de {gradesModalStudent.first_name} {gradesModalStudent.last_name}
|
Notes de {gradesModalStudent.first_name}{' '}
|
||||||
|
{gradesModalStudent.last_name}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{gradesModalStudent.associated_class_name || 'Classe non assignée'}
|
{gradesModalStudent.associated_class_name ||
|
||||||
|
'Classe non assignée'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -497,25 +574,38 @@ export default function Page() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Résumé des moyennes */}
|
{/* Résumé des moyennes */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const subjectAverages = Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => {
|
const subjectAverages = Object.entries(groupedBySubject)
|
||||||
const scores = evaluations
|
.map(([subject, { color, evaluations }]) => {
|
||||||
.filter(e => e.score !== null && e.score !== undefined && !e.is_absent)
|
const scores = evaluations
|
||||||
.map(e => parseFloat(e.score))
|
.filter(
|
||||||
.filter(s => !isNaN(s));
|
(e) =>
|
||||||
const avg = scores.length
|
e.score !== null &&
|
||||||
? scores.reduce((sum, s) => sum + s, 0) / scores.length
|
e.score !== undefined &&
|
||||||
: null;
|
!e.is_absent
|
||||||
return { subject, color, avg };
|
)
|
||||||
}).filter(s => s.avg !== null && !isNaN(s.avg));
|
.map((e) => parseFloat(e.score))
|
||||||
|
.filter((s) => !isNaN(s));
|
||||||
|
const avg = scores.length
|
||||||
|
? scores.reduce((sum, s) => sum + s, 0) /
|
||||||
|
scores.length
|
||||||
|
: null;
|
||||||
|
return { subject, color, avg };
|
||||||
|
})
|
||||||
|
.filter((s) => s.avg !== null && !isNaN(s.avg));
|
||||||
|
|
||||||
const overallAvg = subjectAverages.length
|
const overallAvg = subjectAverages.length
|
||||||
? (subjectAverages.reduce((sum, s) => sum + s.avg, 0) / subjectAverages.length).toFixed(1)
|
? (
|
||||||
|
subjectAverages.reduce((sum, s) => sum + s.avg, 0) /
|
||||||
|
subjectAverages.length
|
||||||
|
).toFixed(1)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gradient-to-r from-emerald-50 to-blue-50 rounded-lg p-4 border border-emerald-100">
|
<div className="bg-gradient-to-r from-emerald-50 to-blue-50 rounded-lg p-4 border border-emerald-100">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-sm font-medium text-gray-600">Résumé</span>
|
<span className="text-sm font-medium text-gray-600">
|
||||||
|
Résumé
|
||||||
|
</span>
|
||||||
{overallAvg !== null && (
|
{overallAvg !== null && (
|
||||||
<span className="text-lg font-bold text-emerald-700">
|
<span className="text-lg font-bold text-emerald-700">
|
||||||
Moyenne générale : {overallAvg}/20
|
Moyenne générale : {overallAvg}/20
|
||||||
@ -532,7 +622,9 @@ export default function Page() {
|
|||||||
className="w-2.5 h-2.5 rounded-full"
|
className="w-2.5 h-2.5 rounded-full"
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
></span>
|
></span>
|
||||||
<span className="text-sm text-gray-700">{subject}</span>
|
<span className="text-sm text-gray-700">
|
||||||
|
{subject}
|
||||||
|
</span>
|
||||||
<span className="text-sm font-semibold text-gray-800">
|
<span className="text-sm font-semibold text-gray-800">
|
||||||
{avg.toFixed(1)}
|
{avg.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
@ -543,134 +635,175 @@ export default function Page() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => {
|
{Object.entries(groupedBySubject).map(
|
||||||
const scores = evaluations
|
([subject, { color, evaluations }]) => {
|
||||||
.filter(e => e.score !== null && e.score !== undefined && !e.is_absent)
|
const scores = evaluations
|
||||||
.map(e => parseFloat(e.score))
|
.filter(
|
||||||
.filter(s => !isNaN(s));
|
(e) =>
|
||||||
const avg = scores.length
|
e.score !== null &&
|
||||||
? (scores.reduce((sum, s) => sum + s, 0) / scores.length).toFixed(1)
|
e.score !== undefined &&
|
||||||
: null;
|
!e.is_absent
|
||||||
return (
|
)
|
||||||
<div key={subject} className="border rounded-lg overflow-hidden">
|
.map((e) => parseFloat(e.score))
|
||||||
|
.filter((s) => !isNaN(s));
|
||||||
|
const avg = scores.length
|
||||||
|
? (
|
||||||
|
scores.reduce((sum, s) => sum + s, 0) /
|
||||||
|
scores.length
|
||||||
|
).toFixed(1)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between px-4 py-3"
|
key={subject}
|
||||||
style={{ backgroundColor: `${color}20` }}
|
className="border rounded-lg overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
<span
|
className="flex items-center justify-between px-4 py-3"
|
||||||
className="w-3 h-3 rounded-full"
|
style={{ backgroundColor: `${color}20` }}
|
||||||
style={{ backgroundColor: color }}
|
>
|
||||||
></span>
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-semibold text-gray-800">{subject}</span>
|
<span
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
></span>
|
||||||
|
<span className="font-semibold text-gray-800">
|
||||||
|
{subject}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{avg !== null && (
|
||||||
|
<span className="text-sm font-bold text-gray-700">
|
||||||
|
Moyenne : {avg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{avg !== null && (
|
<table className="w-full text-sm">
|
||||||
<span className="text-sm font-bold text-gray-700">
|
<thead className="bg-gray-50">
|
||||||
Moyenne : {avg}
|
<tr>
|
||||||
</span>
|
<th className="text-left px-4 py-2 font-medium text-gray-600">
|
||||||
)}
|
Évaluation
|
||||||
</div>
|
</th>
|
||||||
<table className="w-full text-sm">
|
<th className="text-left px-4 py-2 font-medium text-gray-600">
|
||||||
<thead className="bg-gray-50">
|
Période
|
||||||
<tr>
|
</th>
|
||||||
<th className="text-left px-4 py-2 font-medium text-gray-600">Évaluation</th>
|
<th className="text-right px-4 py-2 font-medium text-gray-600">
|
||||||
<th className="text-left px-4 py-2 font-medium text-gray-600">Période</th>
|
Note
|
||||||
<th className="text-right px-4 py-2 font-medium text-gray-600">Note</th>
|
</th>
|
||||||
<th className="text-center px-4 py-2 font-medium text-gray-600 w-24">Actions</th>
|
<th className="text-center px-4 py-2 font-medium text-gray-600 w-24">
|
||||||
</tr>
|
Actions
|
||||||
</thead>
|
</th>
|
||||||
<tbody>
|
|
||||||
{evaluations.map((evalItem) => {
|
|
||||||
const isEditing = editingEvalId === evalItem.id;
|
|
||||||
return (
|
|
||||||
<tr key={evalItem.id} className="border-t hover:bg-gray-50">
|
|
||||||
<td className="px-4 py-2 text-gray-700">
|
|
||||||
{evalItem.evaluation_name || 'Évaluation'}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-gray-500">
|
|
||||||
{evalItem.period || '—'}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right">
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<label className="flex items-center gap-1 text-xs text-gray-600">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editAbsent}
|
|
||||||
onChange={(e) => {
|
|
||||||
setEditAbsent(e.target.checked);
|
|
||||||
if (e.target.checked) setEditScore('');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
Abs
|
|
||||||
</label>
|
|
||||||
{!editAbsent && (
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editScore}
|
|
||||||
onChange={(e) => setEditScore(e.target.value)}
|
|
||||||
min="0"
|
|
||||||
max={evalItem.max_score || 20}
|
|
||||||
step="0.5"
|
|
||||||
className="w-16 text-center px-1 py-0.5 border rounded text-sm"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="text-gray-500">/{evalItem.max_score || 20}</span>
|
|
||||||
</div>
|
|
||||||
) : evalItem.is_absent ? (
|
|
||||||
<span className="text-orange-500 font-medium">Absent</span>
|
|
||||||
) : evalItem.score !== null ? (
|
|
||||||
<span className="font-semibold text-gray-800">
|
|
||||||
{evalItem.score}/{evalItem.max_score || 20}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-400">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-center">
|
|
||||||
{isEditing ? (
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => handleSaveEval(evalItem)}
|
|
||||||
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
|
|
||||||
title="Enregistrer"
|
|
||||||
>
|
|
||||||
<Save size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={cancelEditingEval}
|
|
||||||
className="p-1 text-gray-500 hover:bg-gray-100 rounded"
|
|
||||||
title="Annuler"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => startEditingEval(evalItem)}
|
|
||||||
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
|
|
||||||
title="Modifier"
|
|
||||||
>
|
|
||||||
<Pencil size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteEval(evalItem)}
|
|
||||||
className="p-1 text-red-600 hover:bg-red-50 rounded"
|
|
||||||
title="Supprimer"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);})}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{evaluations.map((evalItem) => {
|
||||||
</div>
|
const isEditing = editingEvalId === evalItem.id;
|
||||||
);
|
return (
|
||||||
})}
|
<tr
|
||||||
|
key={evalItem.id}
|
||||||
|
className="border-t hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 text-gray-700">
|
||||||
|
{evalItem.evaluation_name || 'Évaluation'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500">
|
||||||
|
{evalItem.period || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<label className="flex items-center gap-1 text-xs text-gray-600">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editAbsent}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditAbsent(e.target.checked);
|
||||||
|
if (e.target.checked)
|
||||||
|
setEditScore('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Abs
|
||||||
|
</label>
|
||||||
|
{!editAbsent && (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editScore}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditScore(e.target.value)
|
||||||
|
}
|
||||||
|
min="0"
|
||||||
|
max={evalItem.max_score || 20}
|
||||||
|
step="0.5"
|
||||||
|
className="w-16 text-center px-1 py-0.5 border rounded text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-500">
|
||||||
|
/{evalItem.max_score || 20}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : evalItem.is_absent ? (
|
||||||
|
<span className="text-orange-500 font-medium">
|
||||||
|
Absent
|
||||||
|
</span>
|
||||||
|
) : evalItem.score !== null ? (
|
||||||
|
<span className="font-semibold text-gray-800">
|
||||||
|
{evalItem.score}/
|
||||||
|
{evalItem.max_score || 20}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleSaveEval(evalItem)
|
||||||
|
}
|
||||||
|
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
|
||||||
|
title="Enregistrer"
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={cancelEditingEval}
|
||||||
|
className="p-1 text-gray-500 hover:bg-gray-100 rounded"
|
||||||
|
title="Annuler"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
startEditingEval(evalItem)
|
||||||
|
}
|
||||||
|
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
|
||||||
|
title="Modifier"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteEval(evalItem)
|
||||||
|
}
|
||||||
|
className="p-1 text-red-600 hover:bg-red-50 rounded"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -34,12 +34,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
fetchRegistrationFileGroups,
|
fetchRegistrationFileGroups,
|
||||||
fetchRegistrationSchoolFileMasters,
|
fetchRegistrationSchoolFileMasters,
|
||||||
fetchRegistrationParentFileMasters
|
fetchRegistrationParentFileMasters,
|
||||||
} from '@/app/actions/registerFileGroupAction';
|
} from '@/app/actions/registerFileGroupAction';
|
||||||
import { fetchProfiles } from '@/app/actions/authAction';
|
import { fetchProfiles } from '@/app/actions/authAction';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
import { FE_ADMIN_SUBSCRIPTIONS_URL, BASE_URL } from '@/utils/Url';
|
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import { useNotification } from '@/context/NotificationContext';
|
import { useNotification } from '@/context/NotificationContext';
|
||||||
|
|
||||||
export default function CreateSubscriptionPage() {
|
export default function CreateSubscriptionPage() {
|
||||||
@ -181,7 +182,9 @@ export default function CreateSubscriptionPage() {
|
|||||||
formDataRef.current = formData;
|
formDataRef.current = formData;
|
||||||
}, [formData]);
|
}, [formData]);
|
||||||
|
|
||||||
useEffect(() => { setStudentsPage(1); }, [students]);
|
useEffect(() => {
|
||||||
|
setStudentsPage(1);
|
||||||
|
}, [students]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!formData.guardianEmail) {
|
if (!formData.guardianEmail) {
|
||||||
@ -530,7 +533,7 @@ export default function CreateSubscriptionPage() {
|
|||||||
'Succès'
|
'Succès'
|
||||||
);
|
);
|
||||||
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
logger.error('Erreur lors de la mise à jour du dossier:', error);
|
logger.error('Erreur lors de la mise à jour du dossier:', error);
|
||||||
@ -714,7 +717,10 @@ export default function CreateSubscriptionPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const studentsTotalPages = Math.ceil(students.length / ITEMS_PER_PAGE);
|
const studentsTotalPages = Math.ceil(students.length / ITEMS_PER_PAGE);
|
||||||
const pagedStudents = students.slice((studentsPage - 1) * ITEMS_PER_PAGE, studentsPage * 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
|
||||||
@ -884,12 +890,12 @@ export default function CreateSubscriptionPage() {
|
|||||||
<div className="flex justify-center items-center">
|
<div className="flex justify-center items-center">
|
||||||
{row.photo ? (
|
{row.photo ? (
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${row.photo}`} // Lien vers la photo
|
href={getSecureFileUrl(row.photo)} // Lien vers la photo
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${BASE_URL}${row.photo}`}
|
src={getSecureFileUrl(row.photo)}
|
||||||
alt={`${row.first_name} ${row.last_name}`}
|
alt={`${row.first_name} ${row.last_name}`}
|
||||||
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -36,8 +36,8 @@ import {
|
|||||||
FE_ADMIN_SUBSCRIPTIONS_EDIT_URL,
|
FE_ADMIN_SUBSCRIPTIONS_EDIT_URL,
|
||||||
FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL,
|
FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL,
|
||||||
FE_ADMIN_SUBSCRIPTIONS_CREATE_URL,
|
FE_ADMIN_SUBSCRIPTIONS_CREATE_URL,
|
||||||
BASE_URL,
|
|
||||||
} from '@/utils/Url';
|
} from '@/utils/Url';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
|
|
||||||
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
@ -112,15 +112,29 @@ export default function Page({ params: { locale } }) {
|
|||||||
// Valide le refus
|
// Valide le refus
|
||||||
const handleRefuse = () => {
|
const handleRefuse = () => {
|
||||||
if (!refuseReason.trim()) {
|
if (!refuseReason.trim()) {
|
||||||
showNotification('Merci de préciser la raison du refus.', 'error', 'Erreur');
|
showNotification(
|
||||||
|
'Merci de préciser la raison du refus.',
|
||||||
|
'error',
|
||||||
|
'Erreur'
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('data', JSON.stringify({ status: RegistrationFormStatus.STATUS_ARCHIVED, notes: refuseReason }));
|
formData.append(
|
||||||
|
'data',
|
||||||
|
JSON.stringify({
|
||||||
|
status: RegistrationFormStatus.STATUS_ARCHIVED,
|
||||||
|
notes: refuseReason,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
editRegisterForm(rowToRefuse.student.id, formData, csrfToken)
|
editRegisterForm(rowToRefuse.student.id, formData, csrfToken)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
|
showNotification(
|
||||||
|
'Le dossier a été refusé et archivé.',
|
||||||
|
'success',
|
||||||
|
'Succès'
|
||||||
|
);
|
||||||
setReloadFetch(true);
|
setReloadFetch(true);
|
||||||
setIsRefusePopupOpen(false);
|
setIsRefusePopupOpen(false);
|
||||||
})
|
})
|
||||||
@ -668,12 +682,12 @@ export default function Page({ params: { locale } }) {
|
|||||||
<div className="flex justify-center items-center">
|
<div className="flex justify-center items-center">
|
||||||
{row.student.photo ? (
|
{row.student.photo ? (
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
|
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${BASE_URL}${row.student.photo}`}
|
src={getSecureFileUrl(row.student.photo)}
|
||||||
alt={`${row.student.first_name} ${row.student.last_name}`}
|
alt={`${row.student.first_name} ${row.student.last_name}`}
|
||||||
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
||||||
/>
|
/>
|
||||||
@ -898,7 +912,9 @@ export default function Page({ params: { locale } }) {
|
|||||||
isOpen={isRefusePopupOpen}
|
isOpen={isRefusePopupOpen}
|
||||||
message={
|
message={
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 font-semibold">Veuillez indiquer la raison du refus :</div>
|
<div className="mb-2 font-semibold">
|
||||||
|
Veuillez indiquer la raison du refus :
|
||||||
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={refuseReason}
|
value={refuseReason}
|
||||||
onChange={(e) => setRefuseReason(e.target.value)}
|
onChange={(e) => setRefuseReason(e.target.value)}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {
|
|||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
|
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { BASE_URL } from '@/utils/Url';
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||||
import { useCsrfToken } from '@/context/CsrfContext';
|
import { useCsrfToken } from '@/context/CsrfContext';
|
||||||
import { useClasses } from '@/context/ClassesContext';
|
import { useClasses } from '@/context/ClassesContext';
|
||||||
@ -139,12 +139,12 @@ export default function ParentHomePage() {
|
|||||||
<div className="flex justify-center items-center">
|
<div className="flex justify-center items-center">
|
||||||
{row.student.photo ? (
|
{row.student.photo ? (
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
|
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${BASE_URL}${row.student.photo}`}
|
src={getSecureFileUrl(row.student.photo)}
|
||||||
alt={`${row.student.first_name} ${row.student.last_name}`}
|
alt={`${row.student.first_name} ${row.student.last_name}`}
|
||||||
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
|
||||||
/>
|
/>
|
||||||
@ -225,7 +225,7 @@ export default function ParentHomePage() {
|
|||||||
<Eye className="h-5 w-5" />
|
<Eye className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${row.sepa_file}`}
|
href={getSecureFileUrl(row.sepa_file)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-center w-8 h-8 rounded-full text-green-500 hover:text-green-700"
|
className="flex items-center justify-center w-8 h-8 rounded-full text-green-500 hover:text-green-700"
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
|
|
||||||
const FileAttachment = ({
|
const FileAttachment = ({
|
||||||
fileName,
|
fileName,
|
||||||
@ -16,6 +17,7 @@ const FileAttachment = ({
|
|||||||
fileUrl,
|
fileUrl,
|
||||||
onDownload = null,
|
onDownload = null,
|
||||||
}) => {
|
}) => {
|
||||||
|
const secureUrl = getSecureFileUrl(fileUrl);
|
||||||
// Obtenir l'icône en fonction du type de fichier
|
// Obtenir l'icône en fonction du type de fichier
|
||||||
const getFileIcon = (type) => {
|
const getFileIcon = (type) => {
|
||||||
if (type.startsWith('image/')) {
|
if (type.startsWith('image/')) {
|
||||||
@ -49,9 +51,9 @@ const FileAttachment = ({
|
|||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
if (onDownload) {
|
if (onDownload) {
|
||||||
onDownload();
|
onDownload();
|
||||||
} else if (fileUrl) {
|
} else if (secureUrl) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = fileUrl;
|
link.href = secureUrl;
|
||||||
link.download = fileName;
|
link.download = fileName;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
@ -64,14 +66,14 @@ const FileAttachment = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-sm">
|
<div className="max-w-sm">
|
||||||
{isImage && fileUrl ? (
|
{isImage && secureUrl ? (
|
||||||
// Affichage pour les images
|
// Affichage pour les images
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<img
|
<img
|
||||||
src={fileUrl}
|
src={secureUrl}
|
||||||
alt={fileName}
|
alt={fileName}
|
||||||
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
onClick={() => window.open(fileUrl, '_blank')}
|
onClick={() => window.open(secureUrl, '_blank')}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all rounded-lg flex items-center justify-center">
|
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all rounded-lg flex items-center justify-center">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -2,9 +2,16 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import FormRenderer from '@/components/Form/FormRenderer';
|
import FormRenderer from '@/components/Form/FormRenderer';
|
||||||
import FileUpload from '@/components/Form/FileUpload';
|
import FileUpload from '@/components/Form/FileUpload';
|
||||||
import { CheckCircle, Hourglass, FileText, Download, Upload, XCircle } from 'lucide-react';
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
Hourglass,
|
||||||
|
FileText,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { BASE_URL } from '@/utils/Url';
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
|
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
|
||||||
@ -36,9 +43,13 @@ export default function DynamicFormsList({
|
|||||||
const dataState = { ...prevData };
|
const dataState = { ...prevData };
|
||||||
schoolFileTemplates.forEach((tpl) => {
|
schoolFileTemplates.forEach((tpl) => {
|
||||||
// Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé
|
// Ne mettre à jour que si on n'a pas encore de données locales ou si les données du serveur ont changé
|
||||||
const hasLocalData = prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
|
const hasLocalData =
|
||||||
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
|
prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
|
||||||
|
const hasServerData =
|
||||||
|
existingResponses &&
|
||||||
|
existingResponses[tpl.id] &&
|
||||||
|
Object.keys(existingResponses[tpl.id]).length > 0;
|
||||||
|
|
||||||
if (!hasLocalData && hasServerData) {
|
if (!hasLocalData && hasServerData) {
|
||||||
// Pas de données locales mais données serveur : utiliser les données serveur
|
// Pas de données locales mais données serveur : utiliser les données serveur
|
||||||
dataState[tpl.id] = existingResponses[tpl.id];
|
dataState[tpl.id] = existingResponses[tpl.id];
|
||||||
@ -56,8 +67,11 @@ export default function DynamicFormsList({
|
|||||||
const validationState = { ...prevValidation };
|
const validationState = { ...prevValidation };
|
||||||
schoolFileTemplates.forEach((tpl) => {
|
schoolFileTemplates.forEach((tpl) => {
|
||||||
const hasLocalValidation = prevValidation[tpl.id] === true;
|
const hasLocalValidation = prevValidation[tpl.id] === true;
|
||||||
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
|
const hasServerData =
|
||||||
|
existingResponses &&
|
||||||
|
existingResponses[tpl.id] &&
|
||||||
|
Object.keys(existingResponses[tpl.id]).length > 0;
|
||||||
|
|
||||||
if (!hasLocalValidation && hasServerData) {
|
if (!hasLocalValidation && hasServerData) {
|
||||||
// Pas validé localement mais données serveur : marquer comme validé
|
// Pas validé localement mais données serveur : marquer comme validé
|
||||||
validationState[tpl.id] = true;
|
validationState[tpl.id] = true;
|
||||||
@ -76,13 +90,21 @@ export default function DynamicFormsList({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
|
// Un document est considéré comme "validé" s'il est validé par l'école OU complété localement OU déjà existant
|
||||||
const allFormsValid = schoolFileTemplates.every(
|
const allFormsValid = schoolFileTemplates.every(
|
||||||
tpl => tpl.isValidated === true ||
|
(tpl) =>
|
||||||
|
tpl.isValidated === true ||
|
||||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
||||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
(existingResponses[tpl.id] &&
|
||||||
|
Object.keys(existingResponses[tpl.id]).length > 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
onValidationChange(allFormsValid);
|
onValidationChange(allFormsValid);
|
||||||
}, [formsData, formsValidation, existingResponses, schoolFileTemplates, onValidationChange]);
|
}, [
|
||||||
|
formsData,
|
||||||
|
formsValidation,
|
||||||
|
existingResponses,
|
||||||
|
schoolFileTemplates,
|
||||||
|
onValidationChange,
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gère la soumission d'un formulaire individuel
|
* Gère la soumission d'un formulaire individuel
|
||||||
@ -177,9 +199,9 @@ export default function DynamicFormsList({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Erreur lors de l\'upload du fichier :', error);
|
logger.error("Erreur lors de l'upload du fichier :", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDynamicForm = (template) =>
|
const isDynamicForm = (template) =>
|
||||||
template.formTemplateData &&
|
template.formTemplateData &&
|
||||||
@ -205,11 +227,15 @@ export default function DynamicFormsList({
|
|||||||
<div className="text-sm text-gray-600 mb-4">
|
<div className="text-sm text-gray-600 mb-4">
|
||||||
{/* Compteur x/y : inclut les documents validés */}
|
{/* Compteur x/y : inclut les documents validés */}
|
||||||
{
|
{
|
||||||
schoolFileTemplates.filter(tpl => {
|
schoolFileTemplates.filter((tpl) => {
|
||||||
// Validé ou complété localement
|
// Validé ou complété localement
|
||||||
return tpl.isValidated === true ||
|
return (
|
||||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
tpl.isValidated === true ||
|
||||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0);
|
(formsData[tpl.id] &&
|
||||||
|
Object.keys(formsData[tpl.id]).length > 0) ||
|
||||||
|
(existingResponses[tpl.id] &&
|
||||||
|
Object.keys(existingResponses[tpl.id]).length > 0)
|
||||||
|
);
|
||||||
}).length
|
}).length
|
||||||
}
|
}
|
||||||
{' / '}
|
{' / '}
|
||||||
@ -219,11 +245,13 @@ export default function DynamicFormsList({
|
|||||||
{/* Tri des templates par état */}
|
{/* Tri des templates par état */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// Helper pour état
|
// Helper pour état
|
||||||
const getState = tpl => {
|
const getState = (tpl) => {
|
||||||
if (tpl.isValidated === true) return 0; // validé
|
if (tpl.isValidated === true) return 0; // validé
|
||||||
const isCompletedLocally = !!(
|
const isCompletedLocally = !!(
|
||||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
(formsData[tpl.id] &&
|
||||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
Object.keys(formsData[tpl.id]).length > 0) ||
|
||||||
|
(existingResponses[tpl.id] &&
|
||||||
|
Object.keys(existingResponses[tpl.id]).length > 0)
|
||||||
);
|
);
|
||||||
if (isCompletedLocally) return 1; // complété/en attente
|
if (isCompletedLocally) return 1; // complété/en attente
|
||||||
return 2; // à compléter/refusé
|
return 2; // à compléter/refusé
|
||||||
@ -234,11 +262,17 @@ export default function DynamicFormsList({
|
|||||||
return (
|
return (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{sortedTemplates.map((tpl, index) => {
|
{sortedTemplates.map((tpl, index) => {
|
||||||
const isActive = schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
|
const isActive =
|
||||||
const isValidated = typeof tpl.isValidated === 'boolean' ? tpl.isValidated : undefined;
|
schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
|
||||||
|
const isValidated =
|
||||||
|
typeof tpl.isValidated === 'boolean'
|
||||||
|
? tpl.isValidated
|
||||||
|
: undefined;
|
||||||
const isCompletedLocally = !!(
|
const isCompletedLocally = !!(
|
||||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
(formsData[tpl.id] &&
|
||||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
Object.keys(formsData[tpl.id]).length > 0) ||
|
||||||
|
(existingResponses[tpl.id] &&
|
||||||
|
Object.keys(existingResponses[tpl.id]).length > 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Statut d'affichage
|
// Statut d'affichage
|
||||||
@ -258,8 +292,12 @@ export default function DynamicFormsList({
|
|||||||
borderClass = 'border border-emerald-200';
|
borderClass = 'border border-emerald-200';
|
||||||
textClass = 'text-emerald-700';
|
textClass = 'text-emerald-700';
|
||||||
bgClass = isActive ? 'bg-emerald-200' : bgClass;
|
bgClass = isActive ? 'bg-emerald-200' : bgClass;
|
||||||
borderClass = isActive ? 'border border-emerald-300' : borderClass;
|
borderClass = isActive
|
||||||
textClass = isActive ? 'text-emerald-900 font-semibold' : textClass;
|
? 'border border-emerald-300'
|
||||||
|
: borderClass;
|
||||||
|
textClass = isActive
|
||||||
|
? 'text-emerald-900 font-semibold'
|
||||||
|
: textClass;
|
||||||
canEdit = false;
|
canEdit = false;
|
||||||
} else if (isValidated === false) {
|
} else if (isValidated === false) {
|
||||||
if (isCompletedLocally) {
|
if (isCompletedLocally) {
|
||||||
@ -267,16 +305,24 @@ export default function DynamicFormsList({
|
|||||||
statusColor = 'orange';
|
statusColor = 'orange';
|
||||||
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
||||||
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
||||||
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
|
borderClass = isActive
|
||||||
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
|
? 'border border-orange-300'
|
||||||
|
: 'border border-orange-200';
|
||||||
|
textClass = isActive
|
||||||
|
? 'text-orange-900 font-semibold'
|
||||||
|
: 'text-orange-700';
|
||||||
canEdit = true;
|
canEdit = true;
|
||||||
} else {
|
} else {
|
||||||
statusLabel = 'Refusé';
|
statusLabel = 'Refusé';
|
||||||
statusColor = 'red';
|
statusColor = 'red';
|
||||||
icon = <XCircle className="w-5 h-5 text-red-500" />;
|
icon = <XCircle className="w-5 h-5 text-red-500" />;
|
||||||
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
|
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
|
||||||
borderClass = isActive ? 'border border-red-300' : 'border border-red-200';
|
borderClass = isActive
|
||||||
textClass = isActive ? 'text-red-900 font-semibold' : 'text-red-700';
|
? 'border border-red-300'
|
||||||
|
: 'border border-red-200';
|
||||||
|
textClass = isActive
|
||||||
|
? 'text-red-900 font-semibold'
|
||||||
|
: 'text-red-700';
|
||||||
canEdit = true;
|
canEdit = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -285,8 +331,12 @@ export default function DynamicFormsList({
|
|||||||
statusColor = 'orange';
|
statusColor = 'orange';
|
||||||
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
|
||||||
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
|
||||||
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
|
borderClass = isActive
|
||||||
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
|
? 'border border-orange-300'
|
||||||
|
: 'border border-orange-200';
|
||||||
|
textClass = isActive
|
||||||
|
? 'text-orange-900 font-semibold'
|
||||||
|
: 'text-orange-700';
|
||||||
canEdit = true;
|
canEdit = true;
|
||||||
} else {
|
} else {
|
||||||
statusLabel = 'À compléter';
|
statusLabel = 'À compléter';
|
||||||
@ -294,7 +344,9 @@ export default function DynamicFormsList({
|
|||||||
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
|
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
|
||||||
bgClass = isActive ? 'bg-gray-200' : '';
|
bgClass = isActive ? 'bg-gray-200' : '';
|
||||||
borderClass = isActive ? 'border border-gray-300' : '';
|
borderClass = isActive ? 'border border-gray-300' : '';
|
||||||
textClass = isActive ? 'text-gray-900 font-semibold' : 'text-gray-600';
|
textClass = isActive
|
||||||
|
? 'text-gray-900 font-semibold'
|
||||||
|
: 'text-gray-600';
|
||||||
canEdit = true;
|
canEdit = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -307,13 +359,22 @@ export default function DynamicFormsList({
|
|||||||
? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
|
? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
|
||||||
: `${bgClass} ${borderClass} ${textClass}`
|
: `${bgClass} ${borderClass} ${textClass}`
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setCurrentTemplateIndex(schoolFileTemplates.findIndex(t => t.id === tpl.id))}
|
onClick={() =>
|
||||||
|
setCurrentTemplateIndex(
|
||||||
|
schoolFileTemplates.findIndex((t) => t.id === tpl.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="mr-3">{icon}</span>
|
<span className="mr-3">{icon}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm truncate flex items-center gap-2">
|
<div className="text-sm truncate flex items-center gap-2">
|
||||||
{tpl.formMasterData?.title || tpl.title || tpl.name || 'Formulaire sans nom'}
|
{tpl.formMasterData?.title ||
|
||||||
<span className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}>
|
tpl.title ||
|
||||||
|
tpl.name ||
|
||||||
|
'Formulaire sans nom'}
|
||||||
|
<span
|
||||||
|
className={`ml-2 px-2 py-0.5 rounded bg-${statusColor}-100 text-${statusColor}-700 text-xs font-semibold`}
|
||||||
|
>
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -337,38 +398,56 @@ export default function DynamicFormsList({
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<h3 className="text-xl font-semibold text-gray-800">
|
<h3 className="text-xl font-semibold text-gray-800">
|
||||||
{currentTemplate.name}
|
{currentTemplate.name}
|
||||||
</h3>
|
</h3>
|
||||||
{/* Label d'état */}
|
{/* Label d'état */}
|
||||||
{currentTemplate.isValidated === true ? (
|
{currentTemplate.isValidated === true ? (
|
||||||
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">Validé</span>
|
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">
|
||||||
) : ((formsData[currentTemplate.id] && Object.keys(formsData[currentTemplate.id]).length > 0) ||
|
Validé
|
||||||
(existingResponses[currentTemplate.id] && Object.keys(existingResponses[currentTemplate.id]).length > 0)) ? (
|
</span>
|
||||||
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">Complété</span>
|
) : (formsData[currentTemplate.id] &&
|
||||||
|
Object.keys(formsData[currentTemplate.id]).length > 0) ||
|
||||||
|
(existingResponses[currentTemplate.id] &&
|
||||||
|
Object.keys(existingResponses[currentTemplate.id]).length >
|
||||||
|
0) ? (
|
||||||
|
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-sm font-semibold">
|
||||||
|
Complété
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">Refusé</span>
|
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">
|
||||||
|
Refusé
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{currentTemplate.formTemplateData?.description ||
|
{currentTemplate.formTemplateData?.description ||
|
||||||
currentTemplate.description || ''}
|
currentTemplate.description ||
|
||||||
|
''}
|
||||||
</p>
|
</p>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
Formulaire {(() => {
|
Formulaire{' '}
|
||||||
|
{(() => {
|
||||||
// Trouver l'index du template courant dans la liste triée
|
// Trouver l'index du template courant dans la liste triée
|
||||||
const getState = tpl => {
|
const getState = (tpl) => {
|
||||||
if (tpl.isValidated === true) return 0;
|
if (tpl.isValidated === true) return 0;
|
||||||
const isCompletedLocally = !!(
|
const isCompletedLocally = !!(
|
||||||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
|
(formsData[tpl.id] &&
|
||||||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0)
|
Object.keys(formsData[tpl.id]).length > 0) ||
|
||||||
|
(existingResponses[tpl.id] &&
|
||||||
|
Object.keys(existingResponses[tpl.id]).length > 0)
|
||||||
);
|
);
|
||||||
if (isCompletedLocally) return 1;
|
if (isCompletedLocally) return 1;
|
||||||
return 2;
|
return 2;
|
||||||
};
|
};
|
||||||
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => getState(a) - getState(b));
|
const sortedTemplates = [...schoolFileTemplates].sort(
|
||||||
const idx = sortedTemplates.findIndex(tpl => tpl.id === currentTemplate.id);
|
(a, b) => getState(a) - getState(b)
|
||||||
|
);
|
||||||
|
const idx = sortedTemplates.findIndex(
|
||||||
|
(tpl) => tpl.id === currentTemplate.id
|
||||||
|
);
|
||||||
return idx + 1;
|
return idx + 1;
|
||||||
})()} sur {schoolFileTemplates.length}
|
})()}{' '}
|
||||||
|
sur {schoolFileTemplates.length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -405,14 +484,15 @@ export default function DynamicFormsList({
|
|||||||
// Formulaire existant (PDF, image, etc.)
|
// Formulaire existant (PDF, image, etc.)
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
{/* Cas validé : affichage en iframe */}
|
{/* Cas validé : affichage en iframe */}
|
||||||
{currentTemplate.isValidated === true && currentTemplate.file && (
|
{currentTemplate.isValidated === true &&
|
||||||
<iframe
|
currentTemplate.file && (
|
||||||
src={`${BASE_URL}${currentTemplate.file}`}
|
<iframe
|
||||||
title={currentTemplate.name}
|
src={getSecureFileUrl(currentTemplate.file)}
|
||||||
className="w-full"
|
title={currentTemplate.name}
|
||||||
style={{ height: '600px', border: 'none' }}
|
className="w-full"
|
||||||
/>
|
style={{ height: '600px', border: 'none' }}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cas non validé : bouton télécharger + upload */}
|
{/* Cas non validé : bouton télécharger + upload */}
|
||||||
{currentTemplate.isValidated !== true && (
|
{currentTemplate.isValidated !== true && (
|
||||||
@ -420,9 +500,7 @@ export default function DynamicFormsList({
|
|||||||
{/* Bouton télécharger le document source */}
|
{/* Bouton télécharger le document source */}
|
||||||
{currentTemplate.file && (
|
{currentTemplate.file && (
|
||||||
<a
|
<a
|
||||||
href={`${BASE_URL}${currentTemplate.file}`}
|
href={getSecureFileUrl(currentTemplate.file)}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
|
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
|
||||||
download
|
download
|
||||||
>
|
>
|
||||||
@ -436,7 +514,9 @@ export default function DynamicFormsList({
|
|||||||
<FileUpload
|
<FileUpload
|
||||||
key={currentTemplate.id}
|
key={currentTemplate.id}
|
||||||
selectionMessage={'Sélectionnez le fichier du document'}
|
selectionMessage={'Sélectionnez le fichier du document'}
|
||||||
onFileSelect={(file) => handleUpload(file, currentTemplate)}
|
onFileSelect={(file) =>
|
||||||
|
handleUpload(file, currentTemplate)
|
||||||
|
}
|
||||||
required
|
required
|
||||||
enable={true}
|
enable={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
fetchSchoolFileTemplatesFromRegistrationFiles,
|
fetchSchoolFileTemplatesFromRegistrationFiles,
|
||||||
fetchParentFileTemplatesFromRegistrationFiles,
|
fetchParentFileTemplatesFromRegistrationFiles,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
import { BASE_URL } from '@/utils/Url';
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
const FilesModal = ({
|
const FilesModal = ({
|
||||||
@ -56,27 +56,27 @@ const FilesModal = ({
|
|||||||
registrationFile: selectedRegisterForm.registration_file
|
registrationFile: selectedRegisterForm.registration_file
|
||||||
? {
|
? {
|
||||||
name: 'Fiche élève',
|
name: 'Fiche élève',
|
||||||
url: `${BASE_URL}${selectedRegisterForm.registration_file}`,
|
url: getSecureFileUrl(selectedRegisterForm.registration_file),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
fusionFile: selectedRegisterForm.fusion_file
|
fusionFile: selectedRegisterForm.fusion_file
|
||||||
? {
|
? {
|
||||||
name: 'Documents fusionnés',
|
name: 'Documents fusionnés',
|
||||||
url: `${BASE_URL}${selectedRegisterForm.fusion_file}`,
|
url: getSecureFileUrl(selectedRegisterForm.fusion_file),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
schoolFiles: fetchedSchoolFiles.map((file) => ({
|
schoolFiles: fetchedSchoolFiles.map((file) => ({
|
||||||
name: file.name || 'Document scolaire',
|
name: file.name || 'Document scolaire',
|
||||||
url: file.file ? `${BASE_URL}${file.file}` : null,
|
url: file.file ? getSecureFileUrl(file.file) : null,
|
||||||
})),
|
})),
|
||||||
parentFiles: parentFiles.map((file) => ({
|
parentFiles: parentFiles.map((file) => ({
|
||||||
name: file.master_name || 'Document parent',
|
name: file.master_name || 'Document parent',
|
||||||
url: file.file ? `${BASE_URL}${file.file}` : null,
|
url: file.file ? getSecureFileUrl(file.file) : null,
|
||||||
})),
|
})),
|
||||||
sepaFile: selectedRegisterForm.sepa_file
|
sepaFile: selectedRegisterForm.sepa_file
|
||||||
? {
|
? {
|
||||||
name: 'Mandat SEPA',
|
name: 'Mandat SEPA',
|
||||||
url: `${BASE_URL}${selectedRegisterForm.sepa_file}`,
|
url: getSecureFileUrl(selectedRegisterForm.sepa_file),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import Table from '@/components/Table';
|
import Table from '@/components/Table';
|
||||||
import FileUpload from '@/components/Form/FileUpload';
|
import FileUpload from '@/components/Form/FileUpload';
|
||||||
import { Upload, Eye, Trash2, FileText } from 'lucide-react';
|
import { Upload, Eye, Trash2, FileText } from 'lucide-react';
|
||||||
import { BASE_URL } from '@/utils/Url';
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
|
|
||||||
@ -230,7 +230,7 @@ export default function FilesToUpload({
|
|||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{actionType === 'view' && selectedFile.fileName ? (
|
{actionType === 'view' && selectedFile.fileName ? (
|
||||||
<iframe
|
<iframe
|
||||||
src={`${BASE_URL}${selectedFile.fileName}`}
|
src={getSecureFileUrl(selectedFile.fileName)}
|
||||||
title="Document Viewer"
|
title="Document Viewer"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import logger from '@/utils/logger';
|
|||||||
import SectionHeader from '@/components/SectionHeader';
|
import SectionHeader from '@/components/SectionHeader';
|
||||||
import { User } from 'lucide-react';
|
import { User } from 'lucide-react';
|
||||||
import FileUpload from '@/components/Form/FileUpload';
|
import FileUpload from '@/components/Form/FileUpload';
|
||||||
import { BASE_URL } from '@/utils/Url';
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import { levels, genders } from '@/utils/constants';
|
import { levels, genders } from '@/utils/constants';
|
||||||
|
|
||||||
export default function StudentInfoForm({
|
export default function StudentInfoForm({
|
||||||
@ -57,7 +57,7 @@ export default function StudentInfoForm({
|
|||||||
|
|
||||||
// Convertir la photo en fichier binaire si elle est un chemin ou une URL
|
// Convertir la photo en fichier binaire si elle est un chemin ou une URL
|
||||||
if (photoPath && typeof photoPath === 'string') {
|
if (photoPath && typeof photoPath === 'string') {
|
||||||
fetch(`${BASE_URL}${photoPath}`)
|
fetch(getSecureFileUrl(photoPath))
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Erreur lors de la récupération de la photo.');
|
throw new Error('Erreur lors de la récupération de la photo.');
|
||||||
|
|||||||
@ -3,11 +3,11 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import Popup from '@/components/Popup';
|
import Popup from '@/components/Popup';
|
||||||
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
import ToggleSwitch from '@/components/Form/ToggleSwitch';
|
||||||
import SelectChoice from '@/components/Form/SelectChoice';
|
import SelectChoice from '@/components/Form/SelectChoice';
|
||||||
import { BASE_URL } from '@/utils/Url';
|
|
||||||
import {
|
import {
|
||||||
fetchSchoolFileTemplatesFromRegistrationFiles,
|
fetchSchoolFileTemplatesFromRegistrationFiles,
|
||||||
fetchParentFileTemplatesFromRegistrationFiles,
|
fetchParentFileTemplatesFromRegistrationFiles,
|
||||||
} from '@/app/actions/subscriptionAction';
|
} from '@/app/actions/subscriptionAction';
|
||||||
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
import logger from '@/utils/logger';
|
import logger from '@/utils/logger';
|
||||||
import { School, FileText } from 'lucide-react';
|
import { School, FileText } from 'lucide-react';
|
||||||
import SectionHeader from '@/components/SectionHeader';
|
import SectionHeader from '@/components/SectionHeader';
|
||||||
@ -49,15 +49,18 @@ export default function ValidateSubscription({
|
|||||||
// Parent templates
|
// Parent templates
|
||||||
parentFileTemplates.forEach((tpl, i) => {
|
parentFileTemplates.forEach((tpl, i) => {
|
||||||
if (typeof tpl.isValidated === 'boolean') {
|
if (typeof tpl.isValidated === 'boolean') {
|
||||||
newStatuses[1 + schoolFileTemplates.length + i] = tpl.isValidated ? 'accepted' : 'refused';
|
newStatuses[1 + schoolFileTemplates.length + i] = tpl.isValidated
|
||||||
|
? 'accepted'
|
||||||
|
: 'refused';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setDocStatuses(s => ({ ...s, ...newStatuses }));
|
setDocStatuses((s) => ({ ...s, ...newStatuses }));
|
||||||
}, [schoolFileTemplates, parentFileTemplates]);
|
}, [schoolFileTemplates, parentFileTemplates]);
|
||||||
const [showRefusedPopup, setShowRefusedPopup] = useState(false);
|
const [showRefusedPopup, setShowRefusedPopup] = useState(false);
|
||||||
|
|
||||||
// Affiche la popup de confirmation finale (tous docs validés et classe sélectionnée)
|
// Affiche la popup de confirmation finale (tous docs validés et classe sélectionnée)
|
||||||
const [showFinalValidationPopup, setShowFinalValidationPopup] = useState(false);
|
const [showFinalValidationPopup, setShowFinalValidationPopup] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
associated_class: null,
|
associated_class: null,
|
||||||
@ -131,7 +134,7 @@ export default function ValidateSubscription({
|
|||||||
const handleRefuseDossier = () => {
|
const handleRefuseDossier = () => {
|
||||||
// Message clair avec la liste des documents refusés
|
// Message clair avec la liste des documents refusés
|
||||||
let notes = 'Dossier non validé pour les raisons suivantes :\n';
|
let notes = 'Dossier non validé pour les raisons suivantes :\n';
|
||||||
notes += refusedDocs.map(doc => `- ${doc.name}`).join('\n');
|
notes += refusedDocs.map((doc) => `- ${doc.name}`).join('\n');
|
||||||
const data = {
|
const data = {
|
||||||
status: 2,
|
status: 2,
|
||||||
notes,
|
notes,
|
||||||
@ -177,10 +180,18 @@ export default function ValidateSubscription({
|
|||||||
.filter((doc, idx) => docStatuses[idx] === 'refused');
|
.filter((doc, idx) => docStatuses[idx] === 'refused');
|
||||||
|
|
||||||
// Récupère la liste des documents à cocher (hors fiche élève)
|
// Récupère la liste des documents à cocher (hors fiche élève)
|
||||||
const docIndexes = allTemplates.map((_, idx) => idx).filter(idx => idx !== 0);
|
const docIndexes = allTemplates
|
||||||
const allChecked = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused');
|
.map((_, idx) => idx)
|
||||||
const allValidated = docIndexes.length > 0 && docIndexes.every(idx => docStatuses[idx] === 'accepted');
|
.filter((idx) => idx !== 0);
|
||||||
const hasRefused = docIndexes.some(idx => docStatuses[idx] === 'refused');
|
const allChecked =
|
||||||
|
docIndexes.length > 0 &&
|
||||||
|
docIndexes.every(
|
||||||
|
(idx) => docStatuses[idx] === 'accepted' || docStatuses[idx] === 'refused'
|
||||||
|
);
|
||||||
|
const allValidated =
|
||||||
|
docIndexes.length > 0 &&
|
||||||
|
docIndexes.every((idx) => docStatuses[idx] === 'accepted');
|
||||||
|
const hasRefused = docIndexes.some((idx) => docStatuses[idx] === 'refused');
|
||||||
logger.debug(allTemplates);
|
logger.debug(allTemplates);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -202,7 +213,7 @@ export default function ValidateSubscription({
|
|||||||
{allTemplates[currentTemplateIndex].name || 'Document sans nom'}
|
{allTemplates[currentTemplateIndex].name || 'Document sans nom'}
|
||||||
</h3>
|
</h3>
|
||||||
<iframe
|
<iframe
|
||||||
src={`${BASE_URL}${allTemplates[currentTemplateIndex].file}`}
|
src={getSecureFileUrl(allTemplates[currentTemplateIndex].file)}
|
||||||
title={
|
title={
|
||||||
allTemplates[currentTemplateIndex].type === 'main'
|
allTemplates[currentTemplateIndex].type === 'main'
|
||||||
? 'Document Principal'
|
? 'Document Principal'
|
||||||
@ -252,18 +263,32 @@ export default function ValidateSubscription({
|
|||||||
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
|
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
|
||||||
${docStatuses[index] === 'accepted' ? 'bg-emerald-500 text-white border-emerald-500' : 'bg-white text-emerald-600 border-emerald-300'}`}
|
${docStatuses[index] === 'accepted' ? 'bg-emerald-500 text-white border-emerald-500' : 'bg-white text-emerald-600 border-emerald-300'}`}
|
||||||
aria-pressed={docStatuses[index] === 'accepted'}
|
aria-pressed={docStatuses[index] === 'accepted'}
|
||||||
onClick={e => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDocStatuses(s => ({ ...s, [index]: 'accepted' }));
|
setDocStatuses((s) => ({
|
||||||
|
...s,
|
||||||
|
[index]: 'accepted',
|
||||||
|
}));
|
||||||
// Appel API pour valider le document
|
// Appel API pour valider le document
|
||||||
if (handleValidateOrRefuseDoc) {
|
if (handleValidateOrRefuseDoc) {
|
||||||
let template = null;
|
let template = null;
|
||||||
let type = null;
|
let type = null;
|
||||||
if (index > 0 && index <= schoolFileTemplates.length) {
|
if (
|
||||||
|
index > 0 &&
|
||||||
|
index <= schoolFileTemplates.length
|
||||||
|
) {
|
||||||
template = schoolFileTemplates[index - 1];
|
template = schoolFileTemplates[index - 1];
|
||||||
type = 'school';
|
type = 'school';
|
||||||
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
|
} else if (
|
||||||
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
|
index > schoolFileTemplates.length &&
|
||||||
|
index <=
|
||||||
|
schoolFileTemplates.length +
|
||||||
|
parentFileTemplates.length
|
||||||
|
) {
|
||||||
|
template =
|
||||||
|
parentFileTemplates[
|
||||||
|
index - 1 - schoolFileTemplates.length
|
||||||
|
];
|
||||||
type = 'parent';
|
type = 'parent';
|
||||||
}
|
}
|
||||||
if (template && template.id) {
|
if (template && template.id) {
|
||||||
@ -284,18 +309,29 @@ export default function ValidateSubscription({
|
|||||||
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
|
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400 flex items-center gap-1
|
||||||
${docStatuses[index] === 'refused' ? 'bg-red-500 text-white border-red-500' : 'bg-white text-red-600 border-red-300'}`}
|
${docStatuses[index] === 'refused' ? 'bg-red-500 text-white border-red-500' : 'bg-white text-red-600 border-red-300'}`}
|
||||||
aria-pressed={docStatuses[index] === 'refused'}
|
aria-pressed={docStatuses[index] === 'refused'}
|
||||||
onClick={e => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDocStatuses(s => ({ ...s, [index]: 'refused' }));
|
setDocStatuses((s) => ({ ...s, [index]: 'refused' }));
|
||||||
// Appel API pour refuser le document
|
// Appel API pour refuser le document
|
||||||
if (handleValidateOrRefuseDoc) {
|
if (handleValidateOrRefuseDoc) {
|
||||||
let template = null;
|
let template = null;
|
||||||
let type = null;
|
let type = null;
|
||||||
if (index > 0 && index <= schoolFileTemplates.length) {
|
if (
|
||||||
|
index > 0 &&
|
||||||
|
index <= schoolFileTemplates.length
|
||||||
|
) {
|
||||||
template = schoolFileTemplates[index - 1];
|
template = schoolFileTemplates[index - 1];
|
||||||
type = 'school';
|
type = 'school';
|
||||||
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
|
} else if (
|
||||||
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
|
index > schoolFileTemplates.length &&
|
||||||
|
index <=
|
||||||
|
schoolFileTemplates.length +
|
||||||
|
parentFileTemplates.length
|
||||||
|
) {
|
||||||
|
template =
|
||||||
|
parentFileTemplates[
|
||||||
|
index - 1 - schoolFileTemplates.length
|
||||||
|
];
|
||||||
type = 'parent';
|
type = 'parent';
|
||||||
}
|
}
|
||||||
if (template && template.id) {
|
if (template && template.id) {
|
||||||
@ -351,7 +387,7 @@ export default function ValidateSubscription({
|
|||||||
<div className="mt-auto py-4">
|
<div className="mt-auto py-4">
|
||||||
<Button
|
<Button
|
||||||
text="Soumettre"
|
text="Soumettre"
|
||||||
onClick={e => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé)
|
// 1. Si tous les documents ne sont pas cochés, rien ne se passe (bouton désactivé)
|
||||||
// 2. Si tous cochés et au moins un refusé : popup refus
|
// 2. Si tous cochés et au moins un refusé : popup refus
|
||||||
@ -367,12 +403,14 @@ export default function ValidateSubscription({
|
|||||||
}}
|
}}
|
||||||
primary
|
primary
|
||||||
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
|
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
|
||||||
!allChecked || (allChecked && allValidated && !formData.associated_class)
|
!allChecked ||
|
||||||
|
(allChecked && allValidated && !formData.associated_class)
|
||||||
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
|
||||||
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
: 'bg-emerald-500 text-white hover:bg-emerald-600'
|
||||||
}`}
|
}`}
|
||||||
disabled={
|
disabled={
|
||||||
!allChecked || (allChecked && allValidated && !formData.associated_class)
|
!allChecked ||
|
||||||
|
(allChecked && allValidated && !formData.associated_class)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -391,7 +429,7 @@ export default function ValidateSubscription({
|
|||||||
<span className="font-semibold text-blue-700">{email}</span>
|
<span className="font-semibold text-blue-700">{email}</span>
|
||||||
{' avec la liste des documents non validés :'}
|
{' avec la liste des documents non validés :'}
|
||||||
<ul className="list-disc ml-6 mt-2">
|
<ul className="list-disc ml-6 mt-2">
|
||||||
{refusedDocs.map(doc => (
|
{refusedDocs.map((doc) => (
|
||||||
<li key={doc.idx}>{doc.name}</li>
|
<li key={doc.idx}>{doc.name}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -9,9 +9,7 @@ import { usePopup } from '@/context/PopupContext';
|
|||||||
import { getRightStr } from '@/utils/rights';
|
import { getRightStr } from '@/utils/rights';
|
||||||
import { ChevronDown } from 'lucide-react'; // Import de l'icône
|
import { ChevronDown } from 'lucide-react'; // Import de l'icône
|
||||||
import Image from 'next/image'; // Import du composant Image
|
import Image from 'next/image'; // Import du composant Image
|
||||||
import {
|
import { getSecureFileUrl } from '@/utils/fileUrl';
|
||||||
BASE_URL,
|
|
||||||
} from '@/utils/Url';
|
|
||||||
|
|
||||||
const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
||||||
const {
|
const {
|
||||||
@ -24,7 +22,7 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
|||||||
setSelectedEstablishmentEvaluationFrequency,
|
setSelectedEstablishmentEvaluationFrequency,
|
||||||
setSelectedEstablishmentTotalCapacity,
|
setSelectedEstablishmentTotalCapacity,
|
||||||
selectedEstablishmentLogo,
|
selectedEstablishmentLogo,
|
||||||
setSelectedEstablishmentLogo
|
setSelectedEstablishmentLogo,
|
||||||
} = useEstablishment();
|
} = useEstablishment();
|
||||||
const { isConnected, connectionStatus } = useChatConnection();
|
const { isConnected, connectionStatus } = useChatConnection();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
@ -38,8 +36,7 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
|||||||
user.roles[roleId].establishment__evaluation_frequency;
|
user.roles[roleId].establishment__evaluation_frequency;
|
||||||
const establishmentTotalCapacity =
|
const establishmentTotalCapacity =
|
||||||
user.roles[roleId].establishment__total_capacity;
|
user.roles[roleId].establishment__total_capacity;
|
||||||
const establishmentLogo =
|
const establishmentLogo = user.roles[roleId].establishment__logo;
|
||||||
user.roles[roleId].establishment__logo;
|
|
||||||
setProfileRole(role);
|
setProfileRole(role);
|
||||||
setSelectedEstablishmentId(establishmentId);
|
setSelectedEstablishmentId(establishmentId);
|
||||||
setSelectedEstablishmentEvaluationFrequency(
|
setSelectedEstablishmentEvaluationFrequency(
|
||||||
@ -108,7 +105,11 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
|||||||
<div className="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-md hover:bg-gray-100">
|
<div className="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-md hover:bg-gray-100">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Image
|
<Image
|
||||||
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
|
src={
|
||||||
|
selectedEstablishmentLogo
|
||||||
|
? getSecureFileUrl(selectedEstablishmentLogo)
|
||||||
|
: getGravatarUrl(user?.email)
|
||||||
|
}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
className="w-8 h-8 rounded-full object-cover shadow-md"
|
className="w-8 h-8 rounded-full object-cover shadow-md"
|
||||||
width={32}
|
width={32}
|
||||||
@ -128,7 +129,11 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
|||||||
<div className="flex items-center gap-2 cursor-pointer px-4 bg-white h-24">
|
<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
|
||||||
|
? getSecureFileUrl(selectedEstablishmentLogo)
|
||||||
|
: getGravatarUrl(user?.email)
|
||||||
|
}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
className="w-16 h-16 rounded-full object-cover shadow-md"
|
className="w-16 h-16 rounded-full object-cover shadow-md"
|
||||||
width={64}
|
width={64}
|
||||||
@ -185,15 +190,23 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
|||||||
label: (
|
label: (
|
||||||
<div className="flex items-center text-left">
|
<div className="flex items-center text-left">
|
||||||
<Image
|
<Image
|
||||||
src={establishment.logo ? `${BASE_URL}${establishment.logo}` : getGravatarUrl(user?.email)}
|
src={
|
||||||
|
establishment.logo
|
||||||
|
? getSecureFileUrl(establishment.logo)
|
||||||
|
: getGravatarUrl(user?.email)
|
||||||
|
}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
className="w-8 h-8 rounded-full object-cover shadow-md mr-3"
|
className="w-8 h-8 rounded-full object-cover shadow-md mr-3"
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold ext-sm text-gray-500">{establishment.name}</div>
|
<div className="font-bold ext-sm text-gray-500">
|
||||||
<div className="italic text-sm text-gray-500">{getRightStr(establishment.role_type)}</div>
|
{establishment.name}
|
||||||
|
</div>
|
||||||
|
<div className="italic text-sm text-gray-500">
|
||||||
|
{getRightStr(establishment.role_type)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@ -212,9 +225,10 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
buttonClassName="w-full"
|
buttonClassName="w-full"
|
||||||
menuClassName={compact
|
menuClassName={
|
||||||
? 'absolute right-0 mt-2 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg z-50'
|
compact
|
||||||
: 'absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10'
|
? '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}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
54
Front-End/src/pages/api/download.js
Normal file
54
Front-End/src/pages/api/download.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { getToken } from 'next-auth/jwt';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getToken({
|
||||||
|
req,
|
||||||
|
secret: process.env.AUTH_SECRET,
|
||||||
|
cookieName: 'n3wtschool_session_token',
|
||||||
|
});
|
||||||
|
if (!token?.token) {
|
||||||
|
return res.status(401).json({ error: 'Non authentifié' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path } = req.query;
|
||||||
|
if (!path) {
|
||||||
|
return res.status(400).json({ error: 'Le paramètre "path" est requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backendUrl = `${BACKEND_URL}/Common/serve-file/?path=${encodeURIComponent(path)}`;
|
||||||
|
|
||||||
|
const backendRes = await fetch(backendUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token.token}`,
|
||||||
|
Connection: 'close',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!backendRes.ok) {
|
||||||
|
return res.status(backendRes.status).json({
|
||||||
|
error: `Erreur backend: ${backendRes.status}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
backendRes.headers.get('content-type') || 'application/octet-stream';
|
||||||
|
const contentDisposition = backendRes.headers.get('content-disposition');
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
if (contentDisposition) {
|
||||||
|
res.setHeader('Content-Disposition', contentDisposition);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await backendRes.arrayBuffer());
|
||||||
|
return res.send(buffer);
|
||||||
|
} catch {
|
||||||
|
return res.status(500).json({ error: 'Erreur lors du téléchargement' });
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Front-End/src/utils/fileUrl.js
Normal file
25
Front-End/src/utils/fileUrl.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Construit l'URL sécurisée pour accéder à un fichier media via le proxy Next.js.
|
||||||
|
* Le proxy `/api/download` injecte le JWT côté serveur avant de transmettre au backend Django.
|
||||||
|
*
|
||||||
|
* Gère les chemins relatifs ("/data/some/file.pdf") et les URLs absolues du backend
|
||||||
|
* ("http://backend:8000/data/some/file.pdf").
|
||||||
|
*
|
||||||
|
* @param {string} filePath - Chemin ou URL complète du fichier
|
||||||
|
* @returns {string|null} URL vers /api/download?path=... ou null si pas de chemin
|
||||||
|
*/
|
||||||
|
export const getSecureFileUrl = (filePath) => {
|
||||||
|
if (!filePath) return null;
|
||||||
|
|
||||||
|
// Si c'est une URL absolue, extraire le chemin /data/...
|
||||||
|
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||||
|
try {
|
||||||
|
const url = new URL(filePath);
|
||||||
|
filePath = url.pathname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/api/download?path=${encodeURIComponent(filePath)}`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user