feat: Securisation du téléchargement de fichier

This commit is contained in:
Luc SORIGNET
2026-04-04 13:44:57 +02:00
parent 5f6c015d02
commit a3291262d8
17 changed files with 1176 additions and 566 deletions

View File

@ -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"),
] ]

View 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

View File

@ -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"
/> />

View File

@ -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>

View File

@ -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"
/> />

View File

@ -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)}

View File

@ -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"

View File

@ -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

View File

@ -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}
/> />

View File

@ -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,
}; };

View File

@ -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={{

View File

@ -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.');

View File

@ -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>

View File

@ -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}

View 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' });
}
}

View 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)}`;
};