mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-04-05 12:41:27 +00:00
feat: Securisation du téléchargement de fichier
This commit is contained in:
@ -3,6 +3,7 @@ from django.urls import path, re_path
|
||||
from .views import (
|
||||
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"),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,7 +199,7 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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.');
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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'} l’aide</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'} l’aide
|
||||
</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'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'inscription (groupes/classes).
|
||||
<span className="text-blue-700 font-semibold">
|
||||
Colonne de gauche
|
||||
</span>{' '}
|
||||
: liste des dossiers d'inscription (groupes/classes).
|
||||
<br />
|
||||
<span className="text-emerald-700 font-semibold">Colonne de droite</span> : liste des documents à fournir pour l'inscription.
|
||||
<span className="text-emerald-700 font-semibold">
|
||||
Colonne de droite
|
||||
</span>{' '}
|
||||
: liste des documents à fournir pour l'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'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'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'école, à remplir et/ou signer électroniquement par la famille.
|
||||
<span className="text-yellow-700 font-semibold">
|
||||
Formulaire personnalisé
|
||||
</span>{' '}
|
||||
: créé dynamiquement par l'é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'abord vos dossiers d'inscription avant d'ajouter des documents à fournir.
|
||||
<span className="font-semibold">Astuce :</span> Créez d'abord
|
||||
vos dossiers d'inscription avant d'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'inscription <span className="text-red-500">*</span>
|
||||
Groupes d'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'inscription <span className="text-red-500">*</span>
|
||||
Groupes d'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'inscription <span className="text-red-500">*</span>
|
||||
Groupes d'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}
|
||||
|
||||
54
Front-End/src/pages/api/download.js
Normal file
54
Front-End/src/pages/api/download.js
Normal file
@ -0,0 +1,54 @@
|
||||
import { getToken } from 'next-auth/jwt';
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const token = await getToken({
|
||||
req,
|
||||
secret: process.env.AUTH_SECRET,
|
||||
cookieName: 'n3wtschool_session_token',
|
||||
});
|
||||
if (!token?.token) {
|
||||
return res.status(401).json({ error: 'Non authentifié' });
|
||||
}
|
||||
|
||||
const { path } = req.query;
|
||||
if (!path) {
|
||||
return res.status(400).json({ error: 'Le paramètre "path" est requis' });
|
||||
}
|
||||
|
||||
try {
|
||||
const backendUrl = `${BACKEND_URL}/Common/serve-file/?path=${encodeURIComponent(path)}`;
|
||||
|
||||
const backendRes = await fetch(backendUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token.token}`,
|
||||
Connection: 'close',
|
||||
},
|
||||
});
|
||||
|
||||
if (!backendRes.ok) {
|
||||
return res.status(backendRes.status).json({
|
||||
error: `Erreur backend: ${backendRes.status}`,
|
||||
});
|
||||
}
|
||||
|
||||
const contentType =
|
||||
backendRes.headers.get('content-type') || 'application/octet-stream';
|
||||
const contentDisposition = backendRes.headers.get('content-disposition');
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
if (contentDisposition) {
|
||||
res.setHeader('Content-Disposition', contentDisposition);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await backendRes.arrayBuffer());
|
||||
return res.send(buffer);
|
||||
} catch {
|
||||
return res.status(500).json({ error: 'Erreur lors du téléchargement' });
|
||||
}
|
||||
}
|
||||
25
Front-End/src/utils/fileUrl.js
Normal file
25
Front-End/src/utils/fileUrl.js
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Construit l'URL sécurisée pour accéder à un fichier media via le proxy Next.js.
|
||||
* Le proxy `/api/download` injecte le JWT côté serveur avant de transmettre au backend Django.
|
||||
*
|
||||
* Gère les chemins relatifs ("/data/some/file.pdf") et les URLs absolues du backend
|
||||
* ("http://backend:8000/data/some/file.pdf").
|
||||
*
|
||||
* @param {string} filePath - Chemin ou URL complète du fichier
|
||||
* @returns {string|null} URL vers /api/download?path=... ou null si pas de chemin
|
||||
*/
|
||||
export const getSecureFileUrl = (filePath) => {
|
||||
if (!filePath) return null;
|
||||
|
||||
// Si c'est une URL absolue, extraire le chemin /data/...
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||
try {
|
||||
const url = new URL(filePath);
|
||||
filePath = url.pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return `/api/download?path=${encodeURIComponent(filePath)}`;
|
||||
};
|
||||
Reference in New Issue
Block a user