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 (
DomainListCreateView, DomainDetailView,
CategoryListCreateView, CategoryDetailView,
ServeFileView,
)
urlpatterns = [
@ -11,4 +12,6 @@ urlpatterns = [
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"),
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.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
from django.utils.decorators import method_decorator
@ -117,3 +122,55 @@ class CategoryDetailView(APIView):
return JsonResponse({'message': 'Deleted'}, safe=False)
except Category.DoesNotExist:
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 Button from '@/components/Form/Button';
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 {
fetchStudents,
fetchStudentCompetencies,
@ -147,21 +148,33 @@ export default function StudentGradesPage() {
// Load evaluations for the student
useEffect(() => {
if (student?.associated_class_id && selectedPeriod && selectedEstablishmentId) {
if (
student?.associated_class_id &&
selectedPeriod &&
selectedEstablishmentId
) {
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
// Load evaluations for the class
fetchEvaluations(selectedEstablishmentId, student.associated_class_id, periodString)
fetchEvaluations(
selectedEstablishmentId,
student.associated_class_id,
periodString
)
.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
fetchStudentEvaluations(studentId, null, periodString, null)
.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]);
@ -182,8 +195,12 @@ export default function StudentGradesPage() {
const handleToggleJustify = (absence) => {
const newReason =
absence.type === 'Absence'
? absence.justified ? 2 : 1
: absence.justified ? 4 : 3;
? absence.justified
? 2
: 1
: absence.justified
? 4
: 3;
editAbsences(absence.id, { ...absence, reason: newReason }, csrfToken)
.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) => {
@ -210,8 +229,16 @@ export default function StudentGradesPage() {
try {
await updateStudentEvaluation(studentEvalId, data, csrfToken);
// Reload student evaluations
const periodString = getPeriodString(selectedPeriod, selectedEstablishmentEvaluationFrequency);
const updatedData = await fetchStudentEvaluations(studentId, null, periodString, null);
const periodString = getPeriodString(
selectedPeriod,
selectedEstablishmentEvaluationFrequency
);
const updatedData = await fetchStudentEvaluations(
studentId,
null,
periodString,
null
);
setStudentEvaluationsData(updatedData);
} catch (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">
{student.photo ? (
<img
src={`${BASE_URL}${student.photo}`}
src={getSecureFileUrl(student.photo)}
alt={`${student.first_name} ${student.last_name}`}
className="w-24 h-24 object-cover rounded-full border-4 border-emerald-200 shadow"
/>

View File

@ -1,21 +1,34 @@
'use client';
import React, { useState, useEffect } from 'react';
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 Table from '@/components/Table';
import logger from '@/utils/logger';
import {
BASE_URL,
FE_ADMIN_GRADES_STUDENT_COMPETENCIES_URL,
FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL,
} from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import {
fetchStudents,
fetchStudentCompetencies,
fetchAbsences,
} 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 { useClasses } from '@/context/ClassesContext';
import { useCsrfToken } from '@/context/CsrfContext';
@ -42,9 +55,17 @@ function calcCompetencyStats(data) {
const total = scores.length;
return {
acquired: Math.round((scores.filter((s) => s === 3).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),
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
),
};
}
@ -65,10 +86,26 @@ function getPeriodColumns(frequency) {
}
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: 'notAcquired', label: 'Non acquises', color: 'bg-red-100 text-red-600' },
{ key: 'notEvaluated', label: 'Non évaluées', color: 'bg-gray-100 text-gray-600' },
{
key: 'acquired',
label: 'Acquises',
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) {
@ -97,13 +134,13 @@ function getCurrentPeriodValue(frequency) {
function PercentBadge({ value, loading, color }) {
if (loading) return <span className="text-gray-300 text-xs"></span>;
if (value === null) return <span className="text-gray-400 text-xs"></span>;
const badgeColor = color || (
value >= 75
const badgeColor =
color ||
(value >= 75
? 'bg-emerald-100 text-emerald-700'
: value >= 50
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-600'
);
: 'bg-red-100 text-red-600');
return (
<span
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) {
data.data.forEach((d) =>
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 {
const total = scores.length;
map[studentId] = {
acquired: Math.round((scores.filter((s) => s === 3).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),
acquired: Math.round(
(scores.filter((s) => s === 3).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) => {
try {
await updateStudentEvaluation(evalItem.id, {
score: editAbsent ? null : (editScore === '' ? null : parseFloat(editScore)),
await updateStudentEvaluation(
evalItem.id,
{
score: editAbsent
? null
: editScore === ''
? null
: parseFloat(editScore),
is_absent: editAbsent,
}, csrfToken);
},
csrfToken
);
// Update local state
setStudentEvaluations((prev) =>
prev.map((e) =>
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
)
);
@ -318,7 +384,10 @@ export default function Page() {
{ name: 'Élève', transform: () => null },
{ name: 'Niveau', 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: 'Actions', transform: () => null },
];
@ -331,13 +400,13 @@ export default function Page() {
<div className="flex justify-center items-center">
{student.photo ? (
<a
href={`${BASE_URL}${student.photo}`}
href={getSecureFileUrl(student.photo)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<img
src={`${BASE_URL}${student.photo}`}
src={getSecureFileUrl(student.photo)}
alt={`${student.first_name} ${student.last_name}`}
className="w-10 h-10 object-cover transition-transform duration-200 hover:scale-125 cursor-pointer rounded-full"
/>
@ -345,7 +414,8 @@ export default function Page() {
) : (
<div className="w-10 h-10 flex items-center justify-center bg-gray-200 rounded-full">
<span className="text-gray-500 text-sm font-semibold">
{student.first_name?.[0]}{student.last_name?.[0]}
{student.first_name?.[0]}
{student.last_name?.[0]}
</span>
</div>
)}
@ -364,7 +434,9 @@ export default function Page() {
<button
onClick={(e) => {
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"
>
@ -385,7 +457,10 @@ export default function Page() {
return (
<div className="flex items-center justify-center gap-2">
<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"
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>
<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>
<p className="text-sm text-gray-500">
{gradesModalStudent.associated_class_name || 'Classe non assignée'}
{gradesModalStudent.associated_class_name ||
'Classe non assignée'}
</p>
</div>
<button
@ -497,25 +574,38 @@ export default function Page() {
<div className="space-y-6">
{/* Résumé des moyennes */}
{(() => {
const subjectAverages = Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => {
const subjectAverages = Object.entries(groupedBySubject)
.map(([subject, { color, evaluations }]) => {
const scores = evaluations
.filter(e => e.score !== null && e.score !== undefined && !e.is_absent)
.map(e => parseFloat(e.score))
.filter(s => !isNaN(s));
.filter(
(e) =>
e.score !== null &&
e.score !== undefined &&
!e.is_absent
)
.map((e) => parseFloat(e.score))
.filter((s) => !isNaN(s));
const avg = scores.length
? scores.reduce((sum, s) => sum + s, 0) / scores.length
? scores.reduce((sum, s) => sum + s, 0) /
scores.length
: null;
return { subject, color, avg };
}).filter(s => s.avg !== null && !isNaN(s.avg));
})
.filter((s) => s.avg !== null && !isNaN(s.avg));
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;
return (
<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">
<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 && (
<span className="text-lg font-bold text-emerald-700">
Moyenne générale : {overallAvg}/20
@ -532,7 +622,9 @@ export default function Page() {
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: color }}
></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">
{avg.toFixed(1)}
</span>
@ -543,16 +635,28 @@ export default function Page() {
);
})()}
{Object.entries(groupedBySubject).map(([subject, { color, evaluations }]) => {
{Object.entries(groupedBySubject).map(
([subject, { color, evaluations }]) => {
const scores = evaluations
.filter(e => e.score !== null && e.score !== undefined && !e.is_absent)
.map(e => parseFloat(e.score))
.filter(s => !isNaN(s));
.filter(
(e) =>
e.score !== null &&
e.score !== undefined &&
!e.is_absent
)
.map((e) => parseFloat(e.score))
.filter((s) => !isNaN(s));
const avg = scores.length
? (scores.reduce((sum, s) => sum + s, 0) / scores.length).toFixed(1)
? (
scores.reduce((sum, s) => sum + s, 0) /
scores.length
).toFixed(1)
: null;
return (
<div key={subject} className="border rounded-lg overflow-hidden">
<div
key={subject}
className="border rounded-lg overflow-hidden"
>
<div
className="flex items-center justify-between px-4 py-3"
style={{ backgroundColor: `${color}20` }}
@ -562,7 +666,9 @@ export default function Page() {
className="w-3 h-3 rounded-full"
style={{ backgroundColor: color }}
></span>
<span className="font-semibold text-gray-800">{subject}</span>
<span className="font-semibold text-gray-800">
{subject}
</span>
</div>
{avg !== null && (
<span className="text-sm font-bold text-gray-700">
@ -573,17 +679,28 @@ export default function Page() {
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="text-left px-4 py-2 font-medium text-gray-600">Évaluation</th>
<th className="text-left px-4 py-2 font-medium text-gray-600">Période</th>
<th className="text-right px-4 py-2 font-medium text-gray-600">Note</th>
<th className="text-center px-4 py-2 font-medium text-gray-600 w-24">Actions</th>
<th className="text-left px-4 py-2 font-medium text-gray-600">
Évaluation
</th>
<th className="text-left px-4 py-2 font-medium text-gray-600">
Période
</th>
<th className="text-right px-4 py-2 font-medium text-gray-600">
Note
</th>
<th className="text-center px-4 py-2 font-medium text-gray-600 w-24">
Actions
</th>
</tr>
</thead>
<tbody>
{evaluations.map((evalItem) => {
const isEditing = editingEvalId === evalItem.id;
return (
<tr key={evalItem.id} className="border-t hover:bg-gray-50">
<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>
@ -599,7 +716,8 @@ export default function Page() {
checked={editAbsent}
onChange={(e) => {
setEditAbsent(e.target.checked);
if (e.target.checked) setEditScore('');
if (e.target.checked)
setEditScore('');
}}
/>
Abs
@ -608,20 +726,27 @@ export default function Page() {
<input
type="number"
value={editScore}
onChange={(e) => setEditScore(e.target.value)}
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>
<span className="text-gray-500">
/{evalItem.max_score || 20}
</span>
</div>
) : evalItem.is_absent ? (
<span className="text-orange-500 font-medium">Absent</span>
<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}
{evalItem.score}/
{evalItem.max_score || 20}
</span>
) : (
<span className="text-gray-400"></span>
@ -631,7 +756,9 @@ export default function Page() {
{isEditing ? (
<div className="flex items-center justify-center gap-1">
<button
onClick={() => handleSaveEval(evalItem)}
onClick={() =>
handleSaveEval(evalItem)
}
className="p-1 text-emerald-600 hover:bg-emerald-50 rounded"
title="Enregistrer"
>
@ -648,14 +775,18 @@ export default function Page() {
) : (
<div className="flex items-center justify-center gap-1">
<button
onClick={() => startEditingEval(evalItem)}
onClick={() =>
startEditingEval(evalItem)
}
className="p-1 text-blue-600 hover:bg-blue-50 rounded"
title="Modifier"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDeleteEval(evalItem)}
onClick={() =>
handleDeleteEval(evalItem)
}
className="p-1 text-red-600 hover:bg-red-50 rounded"
title="Supprimer"
>
@ -665,12 +796,14 @@ export default function Page() {
)}
</td>
</tr>
);})}
);
})}
</tbody>
</table>
</div>
);
})}
}
)}
</div>
)}
</div>

View File

@ -34,12 +34,13 @@ import {
import {
fetchRegistrationFileGroups,
fetchRegistrationSchoolFileMasters,
fetchRegistrationParentFileMasters
fetchRegistrationParentFileMasters,
} from '@/app/actions/registerFileGroupAction';
import { fetchProfiles } from '@/app/actions/authAction';
import { useClasses } from '@/context/ClassesContext';
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';
export default function CreateSubscriptionPage() {
@ -181,7 +182,9 @@ export default function CreateSubscriptionPage() {
formDataRef.current = formData;
}, [formData]);
useEffect(() => { setStudentsPage(1); }, [students]);
useEffect(() => {
setStudentsPage(1);
}, [students]);
useEffect(() => {
if (!formData.guardianEmail) {
@ -714,7 +717,10 @@ export default function CreateSubscriptionPage() {
};
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) {
return <Loader />; // Affichez le composant Loader
@ -884,12 +890,12 @@ export default function CreateSubscriptionPage() {
<div className="flex justify-center items-center">
{row.photo ? (
<a
href={`${BASE_URL}${row.photo}`} // Lien vers la photo
href={getSecureFileUrl(row.photo)} // Lien vers la photo
target="_blank"
rel="noopener noreferrer"
>
<img
src={`${BASE_URL}${row.photo}`}
src={getSecureFileUrl(row.photo)}
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"
/>

View File

@ -36,8 +36,8 @@ import {
FE_ADMIN_SUBSCRIPTIONS_EDIT_URL,
FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL,
FE_ADMIN_SUBSCRIPTIONS_CREATE_URL,
BASE_URL,
} from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import { useCsrfToken } from '@/context/CsrfContext';
@ -112,15 +112,29 @@ export default function Page({ params: { locale } }) {
// Valide le refus
const handleRefuse = () => {
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;
}
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)
.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);
setIsRefusePopupOpen(false);
})
@ -668,12 +682,12 @@ export default function Page({ params: { locale } }) {
<div className="flex justify-center items-center">
{row.student.photo ? (
<a
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
target="_blank"
rel="noopener noreferrer"
>
<img
src={`${BASE_URL}${row.student.photo}`}
src={getSecureFileUrl(row.student.photo)}
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"
/>
@ -898,7 +912,9 @@ export default function Page({ params: { locale } }) {
isOpen={isRefusePopupOpen}
message={
<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
value={refuseReason}
onChange={(e) => setRefuseReason(e.target.value)}

View File

@ -19,7 +19,7 @@ import {
} from '@/app/actions/subscriptionAction';
import { fetchUpcomingEvents } from '@/app/actions/planningAction';
import logger from '@/utils/logger';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useCsrfToken } from '@/context/CsrfContext';
import { useClasses } from '@/context/ClassesContext';
@ -139,12 +139,12 @@ export default function ParentHomePage() {
<div className="flex justify-center items-center">
{row.student.photo ? (
<a
href={`${BASE_URL}${row.student.photo}`} // Lien vers la photo
href={getSecureFileUrl(row.student.photo)} // Lien vers la photo
target="_blank"
rel="noopener noreferrer"
>
<img
src={`${BASE_URL}${row.student.photo}`}
src={getSecureFileUrl(row.student.photo)}
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"
/>
@ -225,7 +225,7 @@ export default function ParentHomePage() {
<Eye className="h-5 w-5" />
</button>
<a
href={`${BASE_URL}${row.sepa_file}`}
href={getSecureFileUrl(row.sepa_file)}
target="_blank"
rel="noopener noreferrer"
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,
AlertCircle,
} from 'lucide-react';
import { getSecureFileUrl } from '@/utils/fileUrl';
const FileAttachment = ({
fileName,
@ -16,6 +17,7 @@ const FileAttachment = ({
fileUrl,
onDownload = null,
}) => {
const secureUrl = getSecureFileUrl(fileUrl);
// Obtenir l'icône en fonction du type de fichier
const getFileIcon = (type) => {
if (type.startsWith('image/')) {
@ -49,9 +51,9 @@ const FileAttachment = ({
const handleDownload = () => {
if (onDownload) {
onDownload();
} else if (fileUrl) {
} else if (secureUrl) {
const link = document.createElement('a');
link.href = fileUrl;
link.href = secureUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
@ -64,14 +66,14 @@ const FileAttachment = ({
return (
<div className="max-w-sm">
{isImage && fileUrl ? (
{isImage && secureUrl ? (
// Affichage pour les images
<div className="relative group">
<img
src={fileUrl}
src={secureUrl}
alt={fileName}
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">
<button

View File

@ -2,9 +2,16 @@
import React, { useState, useEffect } from 'react';
import FormRenderer from '@/components/Form/FormRenderer';
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 { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
/**
* Composant pour afficher et gérer les formulaires dynamiques d'inscription
@ -36,8 +43,12 @@ export default function DynamicFormsList({
const dataState = { ...prevData };
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é
const hasLocalData = prevData[tpl.id] && Object.keys(prevData[tpl.id]).length > 0;
const hasServerData = existingResponses && existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0;
const hasLocalData =
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) {
// Pas de données locales mais données serveur : utiliser les données serveur
@ -56,7 +67,10 @@ export default function DynamicFormsList({
const validationState = { ...prevValidation };
schoolFileTemplates.forEach((tpl) => {
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) {
// Pas validé localement mais données serveur : marquer comme validé
@ -76,13 +90,21 @@ export default function DynamicFormsList({
useEffect(() => {
// 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(
tpl => tpl.isValidated === true ||
(tpl) =>
tpl.isValidated === true ||
(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);
}, [formsData, formsValidation, existingResponses, schoolFileTemplates, onValidationChange]);
}, [
formsData,
formsValidation,
existingResponses,
schoolFileTemplates,
onValidationChange,
]);
/**
* Gère la soumission d'un formulaire individuel
@ -177,9 +199,9 @@ export default function DynamicFormsList({
});
}
} 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) =>
template.formTemplateData &&
@ -205,11 +227,15 @@ export default function DynamicFormsList({
<div className="text-sm text-gray-600 mb-4">
{/* Compteur x/y : inclut les documents validés */}
{
schoolFileTemplates.filter(tpl => {
schoolFileTemplates.filter((tpl) => {
// Validé ou complété localement
return tpl.isValidated === true ||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] && Object.keys(existingResponses[tpl.id]).length > 0);
return (
tpl.isValidated === true ||
(formsData[tpl.id] &&
Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
);
}).length
}
{' / '}
@ -219,11 +245,13 @@ export default function DynamicFormsList({
{/* Tri des templates par état */}
{(() => {
// Helper pour état
const getState = tpl => {
const getState = (tpl) => {
if (tpl.isValidated === true) return 0; // validé
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(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)
);
if (isCompletedLocally) return 1; // complété/en attente
return 2; // à compléter/refusé
@ -234,11 +262,17 @@ export default function DynamicFormsList({
return (
<ul className="space-y-2">
{sortedTemplates.map((tpl, index) => {
const isActive = schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
const isValidated = typeof tpl.isValidated === 'boolean' ? tpl.isValidated : undefined;
const isActive =
schoolFileTemplates[currentTemplateIndex]?.id === tpl.id;
const isValidated =
typeof tpl.isValidated === 'boolean'
? tpl.isValidated
: undefined;
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(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)
);
// Statut d'affichage
@ -258,8 +292,12 @@ export default function DynamicFormsList({
borderClass = 'border border-emerald-200';
textClass = 'text-emerald-700';
bgClass = isActive ? 'bg-emerald-200' : bgClass;
borderClass = isActive ? 'border border-emerald-300' : borderClass;
textClass = isActive ? 'text-emerald-900 font-semibold' : textClass;
borderClass = isActive
? 'border border-emerald-300'
: borderClass;
textClass = isActive
? 'text-emerald-900 font-semibold'
: textClass;
canEdit = false;
} else if (isValidated === false) {
if (isCompletedLocally) {
@ -267,16 +305,24 @@ export default function DynamicFormsList({
statusColor = 'orange';
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
borderClass = isActive
? 'border border-orange-300'
: 'border border-orange-200';
textClass = isActive
? 'text-orange-900 font-semibold'
: 'text-orange-700';
canEdit = true;
} else {
statusLabel = 'Refusé';
statusColor = 'red';
icon = <XCircle className="w-5 h-5 text-red-500" />;
bgClass = isActive ? 'bg-red-200' : 'bg-red-50';
borderClass = isActive ? 'border border-red-300' : 'border border-red-200';
textClass = isActive ? 'text-red-900 font-semibold' : 'text-red-700';
borderClass = isActive
? 'border border-red-300'
: 'border border-red-200';
textClass = isActive
? 'text-red-900 font-semibold'
: 'text-red-700';
canEdit = true;
}
} else {
@ -285,8 +331,12 @@ export default function DynamicFormsList({
statusColor = 'orange';
icon = <Hourglass className="w-5 h-5 text-orange-400" />;
bgClass = isActive ? 'bg-orange-200' : 'bg-orange-50';
borderClass = isActive ? 'border border-orange-300' : 'border border-orange-200';
textClass = isActive ? 'text-orange-900 font-semibold' : 'text-orange-700';
borderClass = isActive
? 'border border-orange-300'
: 'border border-orange-200';
textClass = isActive
? 'text-orange-900 font-semibold'
: 'text-orange-700';
canEdit = true;
} else {
statusLabel = 'À compléter';
@ -294,7 +344,9 @@ export default function DynamicFormsList({
icon = <Hourglass className="w-5 h-5 text-gray-400" />;
bgClass = isActive ? 'bg-gray-200' : '';
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;
}
}
@ -307,13 +359,22 @@ export default function DynamicFormsList({
? `${bgClass.replace('50', '200')} ${borderClass.replace('200', '300')} ${textClass.replace('700', '900')} font-semibold`
: `${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>
<div className="flex-1 min-w-0">
<div className="text-sm truncate flex items-center gap-2">
{tpl.formMasterData?.title || 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`}>
{tpl.formMasterData?.title ||
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}
</span>
</div>
@ -341,34 +402,52 @@ export default function DynamicFormsList({
</h3>
{/* Label d'état */}
{currentTemplate.isValidated === true ? (
<span className="px-2 py-0.5 rounded bg-emerald-100 text-emerald-700 text-sm font-semibold">Validé</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-emerald-100 text-emerald-700 text-sm font-semibold">
Validé
</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>
<p className="text-sm text-gray-600">
{currentTemplate.formTemplateData?.description ||
currentTemplate.description || ''}
currentTemplate.description ||
''}
</p>
<div className="text-xs text-gray-500 mt-1">
Formulaire {(() => {
Formulaire{' '}
{(() => {
// Trouver l'index du template courant dans la liste triée
const getState = tpl => {
const getState = (tpl) => {
if (tpl.isValidated === true) return 0;
const isCompletedLocally = !!(
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(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)
);
if (isCompletedLocally) return 1;
return 2;
};
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => getState(a) - getState(b));
const idx = sortedTemplates.findIndex(tpl => tpl.id === currentTemplate.id);
const sortedTemplates = [...schoolFileTemplates].sort(
(a, b) => getState(a) - getState(b)
);
const idx = sortedTemplates.findIndex(
(tpl) => tpl.id === currentTemplate.id
);
return idx + 1;
})()} sur {schoolFileTemplates.length}
})()}{' '}
sur {schoolFileTemplates.length}
</div>
</div>
@ -405,9 +484,10 @@ export default function DynamicFormsList({
// Formulaire existant (PDF, image, etc.)
<div className="flex flex-col items-center gap-6">
{/* Cas validé : affichage en iframe */}
{currentTemplate.isValidated === true && currentTemplate.file && (
{currentTemplate.isValidated === true &&
currentTemplate.file && (
<iframe
src={`${BASE_URL}${currentTemplate.file}`}
src={getSecureFileUrl(currentTemplate.file)}
title={currentTemplate.name}
className="w-full"
style={{ height: '600px', border: 'none' }}
@ -420,9 +500,7 @@ export default function DynamicFormsList({
{/* Bouton télécharger le document source */}
{currentTemplate.file && (
<a
href={`${BASE_URL}${currentTemplate.file}`}
target="_blank"
rel="noopener noreferrer"
href={getSecureFileUrl(currentTemplate.file)}
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
download
>
@ -436,7 +514,9 @@ export default function DynamicFormsList({
<FileUpload
key={currentTemplate.id}
selectionMessage={'Sélectionnez le fichier du document'}
onFileSelect={(file) => handleUpload(file, currentTemplate)}
onFileSelect={(file) =>
handleUpload(file, currentTemplate)
}
required
enable={true}
/>

View File

@ -5,7 +5,7 @@ import {
fetchSchoolFileTemplatesFromRegistrationFiles,
fetchParentFileTemplatesFromRegistrationFiles,
} from '@/app/actions/subscriptionAction';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import logger from '@/utils/logger';
const FilesModal = ({
@ -56,27 +56,27 @@ const FilesModal = ({
registrationFile: selectedRegisterForm.registration_file
? {
name: 'Fiche élève',
url: `${BASE_URL}${selectedRegisterForm.registration_file}`,
url: getSecureFileUrl(selectedRegisterForm.registration_file),
}
: null,
fusionFile: selectedRegisterForm.fusion_file
? {
name: 'Documents fusionnés',
url: `${BASE_URL}${selectedRegisterForm.fusion_file}`,
url: getSecureFileUrl(selectedRegisterForm.fusion_file),
}
: null,
schoolFiles: fetchedSchoolFiles.map((file) => ({
name: file.name || 'Document scolaire',
url: file.file ? `${BASE_URL}${file.file}` : null,
url: file.file ? getSecureFileUrl(file.file) : null,
})),
parentFiles: parentFiles.map((file) => ({
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
? {
name: 'Mandat SEPA',
url: `${BASE_URL}${selectedRegisterForm.sepa_file}`,
url: getSecureFileUrl(selectedRegisterForm.sepa_file),
}
: null,
};

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import Table from '@/components/Table';
import FileUpload from '@/components/Form/FileUpload';
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 logger from '@/utils/logger';
@ -230,7 +230,7 @@ export default function FilesToUpload({
<div className="mt-4">
{actionType === 'view' && selectedFile.fileName ? (
<iframe
src={`${BASE_URL}${selectedFile.fileName}`}
src={getSecureFileUrl(selectedFile.fileName)}
title="Document Viewer"
className="w-full"
style={{

View File

@ -7,7 +7,7 @@ import logger from '@/utils/logger';
import SectionHeader from '@/components/SectionHeader';
import { User } from 'lucide-react';
import FileUpload from '@/components/Form/FileUpload';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
import { levels, genders } from '@/utils/constants';
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
if (photoPath && typeof photoPath === 'string') {
fetch(`${BASE_URL}${photoPath}`)
fetch(getSecureFileUrl(photoPath))
.then((response) => {
if (!response.ok) {
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 ToggleSwitch from '@/components/Form/ToggleSwitch';
import SelectChoice from '@/components/Form/SelectChoice';
import { BASE_URL } from '@/utils/Url';
import {
fetchSchoolFileTemplatesFromRegistrationFiles,
fetchParentFileTemplatesFromRegistrationFiles,
} from '@/app/actions/subscriptionAction';
import { getSecureFileUrl } from '@/utils/fileUrl';
import logger from '@/utils/logger';
import { School, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
@ -49,15 +49,18 @@ export default function ValidateSubscription({
// Parent templates
parentFileTemplates.forEach((tpl, i) => {
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]);
const [showRefusedPopup, setShowRefusedPopup] = useState(false);
// 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({
associated_class: null,
@ -131,7 +134,7 @@ export default function ValidateSubscription({
const handleRefuseDossier = () => {
// Message clair avec la liste des documents refusés
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 = {
status: 2,
notes,
@ -177,10 +180,18 @@ export default function ValidateSubscription({
.filter((doc, idx) => docStatuses[idx] === 'refused');
// Récupère la liste des documents à cocher (hors fiche élève)
const docIndexes = allTemplates.map((_, idx) => idx).filter(idx => idx !== 0);
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');
const docIndexes = allTemplates
.map((_, idx) => idx)
.filter((idx) => idx !== 0);
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);
return (
@ -202,7 +213,7 @@ export default function ValidateSubscription({
{allTemplates[currentTemplateIndex].name || 'Document sans nom'}
</h3>
<iframe
src={`${BASE_URL}${allTemplates[currentTemplateIndex].file}`}
src={getSecureFileUrl(allTemplates[currentTemplateIndex].file)}
title={
allTemplates[currentTemplateIndex].type === 'main'
? '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
${docStatuses[index] === 'accepted' ? 'bg-emerald-500 text-white border-emerald-500' : 'bg-white text-emerald-600 border-emerald-300'}`}
aria-pressed={docStatuses[index] === 'accepted'}
onClick={e => {
onClick={(e) => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'accepted' }));
setDocStatuses((s) => ({
...s,
[index]: 'accepted',
}));
// Appel API pour valider le document
if (handleValidateOrRefuseDoc) {
let template = null;
let type = null;
if (index > 0 && index <= schoolFileTemplates.length) {
if (
index > 0 &&
index <= schoolFileTemplates.length
) {
template = schoolFileTemplates[index - 1];
type = 'school';
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
} else if (
index > schoolFileTemplates.length &&
index <=
schoolFileTemplates.length +
parentFileTemplates.length
) {
template =
parentFileTemplates[
index - 1 - schoolFileTemplates.length
];
type = 'parent';
}
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
${docStatuses[index] === 'refused' ? 'bg-red-500 text-white border-red-500' : 'bg-white text-red-600 border-red-300'}`}
aria-pressed={docStatuses[index] === 'refused'}
onClick={e => {
onClick={(e) => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'refused' }));
setDocStatuses((s) => ({ ...s, [index]: 'refused' }));
// Appel API pour refuser le document
if (handleValidateOrRefuseDoc) {
let template = null;
let type = null;
if (index > 0 && index <= schoolFileTemplates.length) {
if (
index > 0 &&
index <= schoolFileTemplates.length
) {
template = schoolFileTemplates[index - 1];
type = 'school';
} else if (index > schoolFileTemplates.length && index <= schoolFileTemplates.length + parentFileTemplates.length) {
template = parentFileTemplates[index - 1 - schoolFileTemplates.length];
} else if (
index > schoolFileTemplates.length &&
index <=
schoolFileTemplates.length +
parentFileTemplates.length
) {
template =
parentFileTemplates[
index - 1 - schoolFileTemplates.length
];
type = 'parent';
}
if (template && template.id) {
@ -351,7 +387,7 @@ export default function ValidateSubscription({
<div className="mt-auto py-4">
<Button
text="Soumettre"
onClick={e => {
onClick={(e) => {
e.preventDefault();
// 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
@ -367,12 +403,14 @@ export default function ValidateSubscription({
}}
primary
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-emerald-500 text-white hover:bg-emerald-600'
}`}
disabled={
!allChecked || (allChecked && allValidated && !formData.associated_class)
!allChecked ||
(allChecked && allValidated && !formData.associated_class)
}
/>
</div>
@ -391,7 +429,7 @@ export default function ValidateSubscription({
<span className="font-semibold text-blue-700">{email}</span>
{' avec la liste des documents non validés :'}
<ul className="list-disc ml-6 mt-2">
{refusedDocs.map(doc => (
{refusedDocs.map((doc) => (
<li key={doc.idx}>{doc.name}</li>
))}
</ul>

View File

@ -9,9 +9,7 @@ import { usePopup } from '@/context/PopupContext';
import { getRightStr } from '@/utils/rights';
import { ChevronDown } from 'lucide-react'; // Import de l'icône
import Image from 'next/image'; // Import du composant Image
import {
BASE_URL,
} from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
const {
@ -24,7 +22,7 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
setSelectedEstablishmentEvaluationFrequency,
setSelectedEstablishmentTotalCapacity,
selectedEstablishmentLogo,
setSelectedEstablishmentLogo
setSelectedEstablishmentLogo,
} = useEstablishment();
const { isConnected, connectionStatus } = useChatConnection();
const [dropdownOpen, setDropdownOpen] = useState(false);
@ -38,8 +36,7 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
user.roles[roleId].establishment__evaluation_frequency;
const establishmentTotalCapacity =
user.roles[roleId].establishment__total_capacity;
const establishmentLogo =
user.roles[roleId].establishment__logo;
const establishmentLogo = user.roles[roleId].establishment__logo;
setProfileRole(role);
setSelectedEstablishmentId(establishmentId);
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="relative">
<Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
src={
selectedEstablishmentLogo
? getSecureFileUrl(selectedEstablishmentLogo)
: getGravatarUrl(user?.email)
}
alt="Profile"
className="w-8 h-8 rounded-full object-cover shadow-md"
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="relative">
<Image
src={selectedEstablishmentLogo ? `${BASE_URL}${selectedEstablishmentLogo}` : getGravatarUrl(user?.email)}
src={
selectedEstablishmentLogo
? getSecureFileUrl(selectedEstablishmentLogo)
: getGravatarUrl(user?.email)
}
alt="Profile"
className="w-16 h-16 rounded-full object-cover shadow-md"
width={64}
@ -185,15 +190,23 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
label: (
<div className="flex items-center text-left">
<Image
src={establishment.logo ? `${BASE_URL}${establishment.logo}` : getGravatarUrl(user?.email)}
src={
establishment.logo
? getSecureFileUrl(establishment.logo)
: getGravatarUrl(user?.email)
}
alt="Profile"
className="w-8 h-8 rounded-full object-cover shadow-md mr-3"
width={32}
height={32}
/>
<div>
<div className="font-bold ext-sm text-gray-500">{establishment.name}</div>
<div className="italic text-sm text-gray-500">{getRightStr(establishment.role_type)}</div>
<div className="font-bold ext-sm text-gray-500">
{establishment.name}
</div>
<div className="italic text-sm text-gray-500">
{getRightStr(establishment.role_type)}
</div>
</div>
</div>
),
@ -212,7 +225,8 @@ const ProfileSelector = ({ onRoleChange, className = '', compact = false }) => {
]
}
buttonClassName="w-full"
menuClassName={compact
menuClassName={
compact
? 'absolute right-0 mt-2 min-w-[200px] bg-white border border-gray-200 rounded shadow-lg z-50'
: 'absolute mt-2 w-full bg-white border border-gray-200 rounded shadow-lg z-10'
}

View File

@ -1,12 +1,5 @@
import React, { useState, useEffect } from 'react';
import {
Edit,
Trash2,
FileText,
Star,
ChevronDown,
Plus,
} from 'lucide-react';
import { Edit, Trash2, FileText, Star, ChevronDown, Plus } from 'lucide-react';
import Modal from '@/components/Modal';
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
import {
@ -38,7 +31,7 @@ import DropdownMenu from '@/components/DropdownMenu';
import CheckBox from '@/components/Form/CheckBox';
import Button from '@/components/Form/Button';
import InputText from '@/components/Form/InputText';
import { BASE_URL } from '@/utils/Url';
import { getSecureFileUrl } from '@/utils/fileUrl';
function getItemBgColor(type, selected, forceTheme = false) {
// Colonne gauche : blanc si rien n'est sélectionné, emerald si sélectionné
@ -73,7 +66,9 @@ function SimpleList({
groupDocCount = null,
}) {
return (
<div className={`rounded border border-gray-200 bg-white ${minHeight} flex flex-col`}>
<div
className={`rounded border border-gray-200 bg-white ${minHeight} flex flex-col`}
>
{title && (
<div
className={`
@ -85,7 +80,9 @@ function SimpleList({
${headerClassName}
`}
>
{headerContent ? headerContent : (
{headerContent ? (
headerContent
) : (
<span className="text-base text-gray-700">{title}</span>
)}
</div>
@ -106,11 +103,12 @@ function SimpleList({
? 'z-0 relative'
: '';
const marginFix =
selectable && idx !== items.length - 1
? '-mb-[1px]'
: '';
selectable && idx !== items.length - 1 ? '-mb-[1px]' : '';
let description = '';
if (typeof item.description === 'string' && item.description.trim()) {
if (
typeof item.description === 'string' &&
item.description.trim()
) {
description = item.description;
} else if (
item._type === 'emerald' &&
@ -124,17 +122,17 @@ function SimpleList({
}
const groupsLabel =
showGroups && Array.isArray(item.groups) && item.groups.length > 0
? item.groups.map(g => g.name).join(', ')
? item.groups.map((g) => g.name).join(', ')
: null;
const docCount = groupDocCount && typeof groupDocCount === 'function'
const docCount =
groupDocCount && typeof groupDocCount === 'function'
? groupDocCount(item)
: null;
const showCustomForm =
item._type === 'emerald' &&
Array.isArray(item.formMasterData?.fields) &&
item.formMasterData.fields.length > 0;
const showRequired =
item._type === 'orange' && item.is_required;
const showRequired = item._type === 'orange' && item.is_required;
// Correction du bug liseré : appliquer un z-index élevé au premier item sélectionné
const extraZ = selected && idx === 0 ? 'z-20 relative' : '';
@ -163,7 +161,9 @@ function SimpleList({
</div>
<div className="flex items-center gap-2">
{docCount !== null && (
<span className="text-xs text-blue-700 font-semibold mr-2">{docCount} document{docCount > 1 ? 's' : ''}</span>
<span className="text-xs text-blue-700 font-semibold mr-2">
{docCount} document{docCount > 1 ? 's' : ''}
</span>
)}
{showCustomForm && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border border-yellow-600 bg-yellow-400 text-yellow-900 mr-1">
@ -176,7 +176,9 @@ function SimpleList({
</span>
)}
{showGroups && groupsLabel && (
<span className="text-xs text-gray-500 mr-2">{groupsLabel}</span>
<span className="text-xs text-gray-500 mr-2">
{groupsLabel}
</span>
)}
{actionButtons && actionButtons(item)}
</div>
@ -192,7 +194,7 @@ function SimpleList({
export default function FilesGroupsManagement({
csrfToken,
selectedEstablishmentId,
profileRole
profileRole,
}) {
const [schoolFileMasters, setSchoolFileMasters] = useState([]);
const [parentFiles, setParentFileMasters] = useState([]);
@ -246,7 +248,12 @@ export default function FilesGroupsManagement({
return found || group;
} else {
// C'est un ID
return groups.find((g) => g.id === group) || { id: group, name: 'Groupe inconnu' };
return (
groups.find((g) => g.id === group) || {
id: group,
name: 'Groupe inconnu',
}
);
}
});
return {
@ -323,7 +330,11 @@ export default function FilesGroupsManagement({
const editTemplateMaster = (file) => {
// Si le formulaire n'est pas personnalisé, ouvrir la popup de téléchargement
if (!file.formMasterData || !Array.isArray(file.formMasterData.fields) || file.formMasterData.fields.length === 0) {
if (
!file.formMasterData ||
!Array.isArray(file.formMasterData.fields) ||
file.formMasterData.fields.length === 0
) {
setFileToEdit(file);
setIsFileUploadPopupOpen(true);
setIsEditing(true);
@ -334,7 +345,12 @@ export default function FilesGroupsManagement({
}
};
const handleCreateSchoolFileMaster = ({ name, group_ids, formMasterData, file }) => {
const handleCreateSchoolFileMaster = ({
name,
group_ids,
formMasterData,
file,
}) => {
// Toujours envoyer en FormData, même sans fichier
const dataToSend = new FormData();
const jsonData = {
@ -390,7 +406,7 @@ export default function FilesGroupsManagement({
// Correction : normaliser group_ids pour ne garder que les IDs (number/string)
let normalizedGroupIds = [];
if (Array.isArray(group_ids)) {
normalizedGroupIds = group_ids.map(g =>
normalizedGroupIds = group_ids.map((g) =>
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
);
}
@ -400,7 +416,7 @@ export default function FilesGroupsManagement({
name: name,
groups: normalizedGroupIds,
formMasterData: formMasterData,
establishment: selectedEstablishmentId
establishment: selectedEstablishmentId,
};
dataToSend.append('data', JSON.stringify(jsonData));
@ -432,12 +448,12 @@ export default function FilesGroupsManagement({
const finalFileName = `${cleanName}${extension}`;
// Correction : il faut récupérer le fichier à l'URL d'origine, pas à la nouvelle URL renommée
// On utilise le path original (file) pour le fetch, pas le chemin avec le nouveau nom
fetch(`${BASE_URL}${file}`)
.then(response => {
fetch(getSecureFileUrl(file))
.then((response) => {
if (!response.ok) throw new Error('Fichier distant introuvable');
return response.blob();
})
.then(blob => {
.then((blob) => {
dataToSend.append('file', blob, finalFileName);
editRegistrationSchoolFileMaster(id, dataToSend, csrfToken)
.then((data) => {
@ -461,7 +477,10 @@ export default function FilesGroupsManagement({
});
})
.catch((error) => {
logger.error('Erreur lors de la récupération du fichier existant pour renommage:', error);
logger.error(
'Erreur lors de la récupération du fichier existant pour renommage:',
error
);
showNotification(
'Erreur lors de la récupération du fichier existant pour renommage',
'error',
@ -620,15 +639,28 @@ export default function FilesGroupsManagement({
// Correction du bug : ne pas supprimer l'élément lors de l'édition d'un doc parent
const handleEdit = (id, updatedFile) => {
logger.debug('[FilesGroupsManagement] handleEdit called with:', id, updatedFile);
logger.debug(
'[FilesGroupsManagement] handleEdit called with:',
id,
updatedFile
);
if (typeof updatedFile !== 'object' || updatedFile === null) {
logger.error('[FilesGroupsManagement] handleEdit: updatedFile is not an object', updatedFile);
logger.error(
'[FilesGroupsManagement] handleEdit: updatedFile is not an object',
updatedFile
);
return Promise.reject(new Error('updatedFile is not an object'));
}
logger.debug('[FilesGroupsManagement] handleEdit payload:', JSON.stringify(updatedFile));
logger.debug(
'[FilesGroupsManagement] handleEdit payload:',
JSON.stringify(updatedFile)
);
return editRegistrationParentFileMaster(id, updatedFile, csrfToken)
.then((response) => {
logger.debug('[FilesGroupsManagement] editRegistrationParentFileMaster response:', response);
logger.debug(
'[FilesGroupsManagement] editRegistrationParentFileMaster response:',
response
);
const modifiedFile = response.data || response;
setParentFileMasters((prevFiles) =>
prevFiles.map((file) => (file.id === id ? modifiedFile : file))
@ -654,19 +686,32 @@ export default function FilesGroupsManagement({
const handleDelete = (id) => {
// Vérification avant suppression : afficher une popup de confirmation
setRemovePopupMessage(
'Attention !\nVous êtes sur le point de supprimer la pièce à fournir.\nÊtes-vous sûr(e) de vouloir poursuivre l\'opération ?'
"Attention !\nVous êtes sur le point de supprimer la pièce à fournir.\nÊtes-vous sûr(e) de vouloir poursuivre l'opération ?"
);
setRemovePopupOnConfirm(() => () => {
deleteRegistrationParentFileMaster(id, csrfToken)
.then(() => {
setParentFileMasters((prevFiles) => prevFiles.filter((file) => file.id !== id));
setParentFileMasters((prevFiles) =>
prevFiles.filter((file) => file.id !== id)
);
logger.debug('Document parent supprimé avec succès:', id);
showNotification('La pièce à fournir a été supprimée avec succès.', 'success', 'Succès');
showNotification(
'La pièce à fournir a été supprimée avec succès.',
'success',
'Succès'
);
setRemovePopupVisible(false);
})
.catch((error) => {
logger.error('Erreur lors de la suppression du fichier parent:', error);
showNotification('Erreur lors de la suppression de la pièce à fournir.', 'error', 'Erreur');
logger.error(
'Erreur lors de la suppression du fichier parent:',
error
);
showNotification(
'Erreur lors de la suppression de la pièce à fournir.',
'error',
'Erreur'
);
setRemovePopupVisible(false);
});
});
@ -701,13 +746,28 @@ export default function FilesGroupsManagement({
aria-expanded={showHelp}
aria-controls="aide-inscription"
>
<span className="underline">{showHelp ? 'Masquer' : 'Afficher'} laide</span>
<svg className={`w-4 h-4 transition-transform ${showHelp ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
<span className="underline">
{showHelp ? 'Masquer' : 'Afficher'} laide
</span>
<svg
className={`w-4 h-4 transition-transform ${showHelp ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{showHelp && (
<div id="aide-inscription" className="p-4 bg-blue-50 border border-blue-200 rounded mb-4">
<div
id="aide-inscription"
className="p-4 bg-blue-50 border border-blue-200 rounded mb-4"
>
<h2 className="text-lg font-bold mb-2">
Gestion des dossiers et documents d&apos;inscription
</h2>
@ -715,33 +775,61 @@ export default function FilesGroupsManagement({
<p>
<span className="font-semibold">Organisation de la page :</span>
<br />
<span className="text-blue-700 font-semibold">Colonne de gauche</span> : liste des dossiers d&apos;inscription (groupes/classes).
<span className="text-blue-700 font-semibold">
Colonne de gauche
</span>{' '}
: liste des dossiers d&apos;inscription (groupes/classes).
<br />
<span className="text-emerald-700 font-semibold">Colonne de droite</span> : liste des documents à fournir pour l&apos;inscription.
<span className="text-emerald-700 font-semibold">
Colonne de droite
</span>{' '}
: liste des documents à fournir pour l&apos;inscription.
</p>
<p>
<span className="font-semibold">Ajout de dossiers :</span>
<br />
Cliquez sur le bouton <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-emerald-500 text-white border border-emerald-600">+</span> à droite de la liste pour créer un nouveau dossier d&apos;inscription.
Cliquez sur le bouton{' '}
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-emerald-500 text-white border border-emerald-600">
+
</span>{' '}
à droite de la liste pour créer un nouveau dossier
d&apos;inscription.
</p>
<p>
<span className="font-semibold">Ajout de documents :</span>
<br />
Cliquez sur le bouton <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-emerald-500 text-white border border-emerald-600">+</span> à droite de la liste des documents pour ajouter :
Cliquez sur le bouton{' '}
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-emerald-500 text-white border border-emerald-600">
+
</span>{' '}
à droite de la liste des documents pour ajouter :
</p>
<ul className="list-disc list-inside ml-6">
<li>
<span className="text-yellow-700 font-semibold">Formulaire personnalisé</span> : créé dynamiquement par l&apos;école, à remplir et/ou signer électroniquement par la famille.
<span className="text-yellow-700 font-semibold">
Formulaire personnalisé
</span>{' '}
: créé dynamiquement par l&apos;école, à remplir et/ou signer
électroniquement par la famille.
</li>
<li>
<span className="text-black font-semibold">Formulaire existant</span> : importez un PDF ou autre document à faire remplir.
<span className="text-black font-semibold">
Formulaire existant
</span>{' '}
: importez un PDF ou autre document à faire remplir.
</li>
<li>
<span className="text-orange-700 font-semibold">Pièce à fournir</span> : document à déposer par la famille (ex : RIB, justificatif de domicile).
<span className="text-orange-700 font-semibold">
Pièce à fournir
</span>{' '}
: document à déposer par la famille (ex : RIB, justificatif de
domicile).
</li>
</ul>
<div className="mt-2 text-sm text-gray-600">
<span className="font-semibold">Astuce :</span> Créez d&apos;abord vos dossiers d&apos;inscription avant d&apos;ajouter des documents à fournir.
<span className="font-semibold">Astuce :</span> Créez d&apos;abord
vos dossiers d&apos;inscription avant d&apos;ajouter des documents
à fournir.
</div>
</div>
</div>
@ -764,14 +852,13 @@ export default function FilesGroupsManagement({
filteredParentFiles = parentFiles.filter(
(file) =>
file.groups &&
file.groups.some((gid) =>
(typeof gid === 'object' ? gid.id : gid) === selectedGroupId
file.groups.some(
(gid) => (typeof gid === 'object' ? gid.id : gid) === selectedGroupId
)
);
}
const mergedDocuments =
selectedGroupId
const mergedDocuments = selectedGroupId
? [
...filteredFiles.map((doc) => ({ ...doc, _type: 'emerald' })),
...filteredParentFiles.map((doc) => ({ ...doc, _type: 'orange' })),
@ -783,17 +870,19 @@ export default function FilesGroupsManagement({
const groupId = group.id;
let count = 0;
// Documents école
count += schoolFileMasters.filter(
(file) =>
count += schoolFileMasters.filter((file) =>
Array.isArray(file.groups)
? file.groups.some((g) => (typeof g === 'object' ? g.id : g) === groupId)
? file.groups.some(
(g) => (typeof g === 'object' ? g.id : g) === groupId
)
: false
).length;
// Pièces à fournir
count += parentFiles.filter(
(file) =>
count += parentFiles.filter((file) =>
Array.isArray(file.groups)
? file.groups.some((g) => (typeof g === 'object' ? g.id : g) === groupId)
? file.groups.some(
(g) => (typeof g === 'object' ? g.id : g) === groupId
)
: false
).length;
return count;
@ -840,7 +929,10 @@ export default function FilesGroupsManagement({
actionButtons={(row) => (
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); handleGroupEdit(row); }}
onClick={(e) => {
e.stopPropagation();
handleGroupEdit(row);
}}
className="p-2 rounded-full hover:bg-gray-100 transition"
title="Modifier"
>
@ -849,7 +941,10 @@ export default function FilesGroupsManagement({
</span>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleGroupDelete(row.id); }}
onClick={(e) => {
e.stopPropagation();
handleGroupDelete(row.id);
}}
className="p-2 rounded-full hover:bg-gray-100 transition"
title="Supprimer"
>
@ -894,7 +989,8 @@ export default function FilesGroupsManagement({
Formulaire existant
</span>
),
onClick: () => handleDocDropdownSelect('formulaire_existant'),
onClick: () =>
handleDocDropdownSelect('formulaire_existant'),
},
{
type: 'item',
@ -1008,12 +1104,18 @@ export default function FilesGroupsManagement({
setIsEditing(false);
}
}}
title={isEditing ? 'Modification du formulaire' : 'Créer un formulaire personnalisé'}
title={
isEditing
? 'Modification du formulaire'
: 'Créer un formulaire personnalisé'
}
>
<div className="w-11/12 h-5/6 max-w-5xl max-h-[90vh] overflow-y-auto">
<FormTemplateBuilder
onSave={(data) => {
(isEditing ? handleEditSchoolFileMaster : handleCreateSchoolFileMaster)(data);
(isEditing
? handleEditSchoolFileMaster
: handleCreateSchoolFileMaster)(data);
setIsModalOpen(false);
}}
initialData={isEditing ? fileToEdit : undefined}
@ -1027,15 +1129,25 @@ export default function FilesGroupsManagement({
<Modal
isOpen={isFileUploadPopupOpen}
setIsOpen={setIsFileUploadPopupOpen}
title={fileToEdit && fileToEdit.id ? 'Modifier le document existant' : 'Télécharger un document existant'}
title={
fileToEdit && fileToEdit.id
? 'Modifier le document existant'
: 'Télécharger un document existant'
}
>
<div className="w-full max-h-[90vh] overflow-y-auto">
{fileToEdit && fileToEdit.id ? (
<form
className="flex flex-col gap-4 w-full"
onSubmit={e => {
onSubmit={(e) => {
e.preventDefault();
if (!fileToEdit?.name || !fileToEdit?.groups || fileToEdit.groups.length === 0 || !fileToEdit?.file) return;
if (
!fileToEdit?.name ||
!fileToEdit?.groups ||
fileToEdit.groups.length === 0 ||
!fileToEdit?.file
)
return;
if (isEditing) {
handleEditSchoolFileMaster({
id: fileToEdit.id,
@ -1059,30 +1171,38 @@ export default function FilesGroupsManagement({
label="Nom du document"
name="name"
value={fileToEdit?.name || ''}
onChange={e => setFileToEdit({ ...fileToEdit, name: e.target.value })}
onChange={(e) =>
setFileToEdit({ ...fileToEdit, name: e.target.value })
}
required
/>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Groupes d&apos;inscription <span className="text-red-500">*</span>
Groupes d&apos;inscription{' '}
<span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
{groups && groups.length > 0 ? (
groups.map((group) => {
const selectedGroupIds = (fileToEdit?.groups || []).map(g =>
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
const selectedGroupIds = (fileToEdit?.groups || []).map(
(g) =>
typeof g === 'object' && g !== null && 'id' in g
? g.id
: g
);
return (
<CheckBox
key={group.id}
item={{ id: group.id }}
formData={{
groups: selectedGroupIds
groups: selectedGroupIds,
}}
handleChange={() => {
let group_ids = selectedGroupIds;
if (group_ids.includes(group.id)) {
group_ids = group_ids.filter((id) => id !== group.id);
group_ids = group_ids.filter(
(id) => id !== group.id
);
} else {
group_ids = [...group_ids, group.id];
}
@ -1104,14 +1224,16 @@ export default function FilesGroupsManagement({
{fileToEdit?.file && (
<div className="flex items-center gap-2 mb-2">
<FileText className="w-5 h-5 text-gray-600" />
<span className="text-sm truncate">{fileToEdit.file.name || fileToEdit.file.path || 'Document sélectionné'}</span>
<span className="text-sm truncate">
{fileToEdit.file.name ||
fileToEdit.file.path ||
'Document sélectionné'}
</span>
</div>
)}
<FileUpload
selectionMessage="Sélectionnez le fichier du document"
onFileSelect={file =>
setFileToEdit({ ...fileToEdit, file })
}
onFileSelect={(file) => setFileToEdit({ ...fileToEdit, file })}
required
enable
/>
@ -1131,9 +1253,15 @@ export default function FilesGroupsManagement({
) : (
<form
className="flex flex-col gap-4 w-full"
onSubmit={e => {
onSubmit={(e) => {
e.preventDefault();
if (!fileToEdit?.name || !fileToEdit?.groups || fileToEdit.groups.length === 0 || !fileToEdit?.file) return;
if (
!fileToEdit?.name ||
!fileToEdit?.groups ||
fileToEdit.groups.length === 0 ||
!fileToEdit?.file
)
return;
handleCreateSchoolFileMaster({
name: fileToEdit.name,
group_ids: fileToEdit.groups,
@ -1147,30 +1275,38 @@ export default function FilesGroupsManagement({
label="Nom du document"
name="name"
value={fileToEdit?.name || ''}
onChange={e => setFileToEdit({ ...fileToEdit, name: e.target.value })}
onChange={(e) =>
setFileToEdit({ ...fileToEdit, name: e.target.value })
}
required
/>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Groupes d&apos;inscription <span className="text-red-500">*</span>
Groupes d&apos;inscription{' '}
<span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
{groups && groups.length > 0 ? (
groups.map((group) => {
const selectedGroupIds = (fileToEdit?.groups || []).map(g =>
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
const selectedGroupIds = (fileToEdit?.groups || []).map(
(g) =>
typeof g === 'object' && g !== null && 'id' in g
? g.id
: g
);
return (
<CheckBox
key={group.id}
item={{ id: group.id }}
formData={{
groups: selectedGroupIds
groups: selectedGroupIds,
}}
handleChange={() => {
let group_ids = selectedGroupIds;
if (group_ids.includes(group.id)) {
group_ids = group_ids.filter((id) => id !== group.id);
group_ids = group_ids.filter(
(id) => id !== group.id
);
} else {
group_ids = [...group_ids, group.id];
}
@ -1190,9 +1326,7 @@ export default function FilesGroupsManagement({
</div>
<FileUpload
selectionMessage="Sélectionnez le fichier du document"
onFileSelect={file =>
setFileToEdit({ ...fileToEdit, file })
}
onFileSelect={(file) => setFileToEdit({ ...fileToEdit, file })}
required
enable
/>
@ -1229,13 +1363,14 @@ export default function FilesGroupsManagement({
<div className="w-full max-w-md max-h-[90vh] overflow-y-auto">
<form
className="flex flex-col gap-4"
onSubmit={e => {
onSubmit={(e) => {
e.preventDefault();
if (
!editingParentFile?.name ||
!editingParentFile?.groups ||
editingParentFile.groups.length === 0
) return;
)
return;
const payload = {
name: editingParentFile.name,
description: editingParentFile.description || '',
@ -1255,41 +1390,61 @@ export default function FilesGroupsManagement({
label="Nom de la pièce à fournir"
name="name"
value={editingParentFile?.name || ''}
onChange={e => setEditingParentFile({ ...editingParentFile, name: e.target.value })}
onChange={(e) =>
setEditingParentFile({
...editingParentFile,
name: e.target.value,
})
}
required
/>
<InputText
label="Description"
name="description"
value={editingParentFile?.description || ''}
onChange={e => setEditingParentFile({ ...editingParentFile, description: e.target.value })}
onChange={(e) =>
setEditingParentFile({
...editingParentFile,
description: e.target.value,
})
}
required={false}
/>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Groupes d&apos;inscription <span className="text-red-500">*</span>
Groupes d&apos;inscription{' '}
<span className="text-red-500">*</span>
</label>
<div className="flex flex-wrap gap-4 max-h-32 overflow-y-auto border rounded-md p-2">
{groups && groups.length > 0 ? (
groups.map((group) => {
const selectedGroupIds = (editingParentFile?.groups || []).map(g =>
typeof g === 'object' && g !== null && 'id' in g ? g.id : g
const selectedGroupIds = (
editingParentFile?.groups || []
).map((g) =>
typeof g === 'object' && g !== null && 'id' in g
? g.id
: g
);
return (
<CheckBox
key={group.id}
item={{ id: group.id }}
formData={{
groups: selectedGroupIds
groups: selectedGroupIds,
}}
handleChange={() => {
let group_ids = selectedGroupIds;
if (group_ids.includes(group.id)) {
group_ids = group_ids.filter((id) => id !== group.id);
group_ids = group_ids.filter(
(id) => id !== group.id
);
} else {
group_ids = [...group_ids, group.id];
}
setEditingParentFile({ ...editingParentFile, groups: group_ids });
setEditingParentFile({
...editingParentFile,
groups: group_ids,
});
}}
fieldName="groups"
itemLabelFunc={() => group.name}

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