feat: Finalisation formulaire dynamique

This commit is contained in:
N3WT DE COMPET
2026-04-04 20:08:25 +02:00
parent ae06b6fef7
commit 90b0d14418
29 changed files with 1071 additions and 306 deletions

View File

@ -0,0 +1,225 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { ArrowLeft } from 'lucide-react';
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
import { useEstablishment } from '@/context/EstablishmentContext';
import { useCsrfToken } from '@/context/CsrfContext';
import {
fetchRegistrationFileGroups,
fetchRegistrationSchoolFileMasterById,
createRegistrationSchoolFileMaster,
editRegistrationSchoolFileMaster,
} from '@/app/actions/registerFileGroupAction';
import { getSecureFileUrl } from '@/utils/fileUrl';
import logger from '@/utils/logger';
import { useNotification } from '@/context/NotificationContext';
import { FE_ADMIN_STRUCTURE_URL } from '@/utils/Url';
export default function FormBuilderPage() {
const searchParams = useSearchParams();
const router = useRouter();
const { selectedEstablishmentId } = useEstablishment();
const csrfToken = useCsrfToken();
const { showNotification } = useNotification();
const formId = searchParams.get('id');
const preGroupId = searchParams.get('groupId');
const isEditing = !!formId;
const [groups, setGroups] = useState([]);
const [initialData, setInitialData] = useState(null);
const [loading, setLoading] = useState(true);
const [uploadedFile, setUploadedFile] = useState(null);
const [existingFileUrl, setExistingFileUrl] = useState(null);
const normalizeBackendFile = (rawFile, rawFileUrl) => {
if (typeof rawFileUrl === 'string' && rawFileUrl.trim()) {
return rawFileUrl;
}
if (typeof rawFile === 'string' && rawFile.trim()) {
return rawFile;
}
if (rawFile && typeof rawFile === 'object') {
if (typeof rawFile.url === 'string' && rawFile.url.trim()) {
return rawFile.url;
}
if (typeof rawFile.path === 'string' && rawFile.path.trim()) {
return rawFile.path;
}
if (typeof rawFile.name === 'string' && rawFile.name.trim()) {
return rawFile.name;
}
}
return null;
};
const previewFileUrl = useMemo(() => {
if (uploadedFile instanceof File) {
return URL.createObjectURL(uploadedFile);
}
return existingFileUrl || null;
}, [uploadedFile, existingFileUrl]);
useEffect(() => {
return () => {
if (previewFileUrl && previewFileUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewFileUrl);
}
};
}, [previewFileUrl]);
useEffect(() => {
if (!selectedEstablishmentId) return;
Promise.all([
fetchRegistrationFileGroups(selectedEstablishmentId),
formId ? fetchRegistrationSchoolFileMasterById(formId) : Promise.resolve(null),
])
.then(([groupsData, formData]) => {
setGroups(groupsData || []);
if (formData) {
setInitialData(formData);
const resolvedFile = normalizeBackendFile(
formData.file,
formData.file_url
);
if (resolvedFile) {
setExistingFileUrl(resolvedFile);
}
} else if (preGroupId) {
setInitialData({ groups: [{ id: Number(preGroupId) }] });
}
})
.catch((err) => {
logger.error('Error loading FormBuilder data:', err);
})
.finally(() => {
setLoading(false);
});
}, [selectedEstablishmentId, formId, preGroupId]);
const buildFormData = async (name, group_ids, formMasterData) => {
const dataToSend = new FormData();
dataToSend.append(
'data',
JSON.stringify({
name,
groups: group_ids,
formMasterData,
establishment: selectedEstablishmentId,
})
);
if (uploadedFile instanceof File) {
const ext =
uploadedFile.name.lastIndexOf('.') !== -1
? uploadedFile.name.substring(uploadedFile.name.lastIndexOf('.'))
: '';
const cleanName = (name || 'document')
.replace(/[^a-zA-Z0-9_\-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
dataToSend.append('file', uploadedFile, `${cleanName}${ext}`);
} else if (existingFileUrl && isEditing) {
const lastDot = existingFileUrl.lastIndexOf('.');
const ext = lastDot !== -1 ? existingFileUrl.substring(lastDot) : '';
const cleanName = (name || 'document')
.replace(/[^a-zA-Z0-9_\-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
try {
const resp = await fetch(getSecureFileUrl(existingFileUrl));
if (resp.ok) {
const blob = await resp.blob();
dataToSend.append('file', blob, `${cleanName}${ext}`);
}
} catch (e) {
logger.error('Could not re-fetch existing file:', e);
}
}
return dataToSend;
};
const handleSave = async ({ name, group_ids, formMasterData, id }) => {
const hasFileField = (formMasterData?.fields || []).some(
(field) => field.type === 'file'
);
const hasUploadedDocument =
uploadedFile instanceof File || Boolean(existingFileUrl);
if (hasFileField && !hasUploadedDocument) {
showNotification(
'Un document PDF doit être uploadé si le formulaire contient un champ fichier.',
'error',
'Erreur'
);
return;
}
try {
const dataToSend = await buildFormData(name, group_ids, formMasterData);
if (isEditing) {
await editRegistrationSchoolFileMaster(id || formId, dataToSend, csrfToken);
showNotification(
`Le formulaire "${name}" a été modifié avec succès.`,
'success',
'Succès'
);
} else {
await createRegistrationSchoolFileMaster(dataToSend, csrfToken);
showNotification(
`Le formulaire "${name}" a été créé avec succès.`,
'success',
'Succès'
);
}
router.push(FE_ADMIN_STRUCTURE_URL);
} catch (err) {
logger.error('Error saving form:', err);
showNotification('Erreur lors de la sauvegarde du formulaire', 'error', 'Erreur');
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<p className="text-gray-500">Chargement...</p>
</div>
);
}
return (
<div className="w-full min-h-screen bg-neutral">
{/* Header sticky */}
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-4 py-3 flex items-center gap-4">
<button
onClick={() => router.push(FE_ADMIN_STRUCTURE_URL)}
className="flex items-center gap-2 text-primary hover:text-secondary font-label font-medium transition-colors"
>
<ArrowLeft size={20} />
Retour
</button>
<h1 className="text-lg font-headline font-semibold text-gray-800">
{isEditing ? 'Modifier le formulaire' : 'Créer un formulaire personnalisé'}
</h1>
</div>
<div className="max-w-5xl mx-auto px-4 py-6 space-y-4">
{/* FormTemplateBuilder */}
<FormTemplateBuilder
onSave={handleSave}
initialData={initialData}
groups={groups}
isEditing={isEditing}
masterFile={previewFileUrl}
onMasterFileUpload={(file) => setUploadedFile(file)}
/>
</div>
</div>
);
}

View File

@ -26,6 +26,10 @@ export const fetchRegistrationSchoolFileMasters = (establishment) => {
return fetchWithAuth(url);
};
export const fetchRegistrationSchoolFileMasterById = (id) => {
return fetchWithAuth(`${BE_SUBSCRIPTION_REGISTRATION_SCHOOL_FILE_MASTERS_URL}/${id}`);
};
export const fetchRegistrationParentFileMasters = (establishment) => {
const url = `${BE_SUBSCRIPTION_REGISTRATION_PARENT_FILE_MASTERS_URL}?establishment_id=${establishment}`;
return fetchWithAuth(url);

View File

@ -3,6 +3,7 @@ import { useForm, Controller } from 'react-hook-form';
import InputTextIcon from './InputTextIcon';
import SelectChoice from './SelectChoice';
import Button from './Button';
import FileUpload from './FileUpload';
import IconSelector from './IconSelector';
import * as LucideIcons from 'lucide-react';
import { FIELD_TYPES } from './FormTypes';
@ -14,6 +15,8 @@ export default function AddFieldModal({
onSubmit,
editingField = null,
editingIndex = -1,
hasMasterFile = false,
onMasterFileUpload,
}) {
const isEditing = editingIndex >= 0;
@ -29,6 +32,7 @@ export default function AddFieldModal({
acceptTypes: '',
maxSize: 5, // 5MB par défaut
checked: false,
masterFileToUpload: null,
validation: {
pattern: '',
minLength: '',
@ -56,6 +60,7 @@ export default function AddFieldModal({
acceptTypes: '',
maxSize: 5,
checked: false,
masterFileToUpload: null,
signatureData: '',
backgroundColor: '#ffffff',
penColor: '#000000',
@ -492,6 +497,31 @@ export default function AddFieldModal({
{currentField.type === 'file' && (
<>
<div className="rounded border border-gray-200 bg-gray-50 p-3">
<label className="block text-sm font-medium text-gray-700 mb-2">
Document PDF du formulaire{' '}
<span className="text-red-500">*</span>
</label>
<FileUpload
selectionMessage="Uploader le PDF à afficher dans l'aperçu"
onFileSelect={(file) => {
setCurrentField((prev) => ({
...prev,
masterFileToUpload: file,
}));
if (onMasterFileUpload) {
onMasterFileUpload(file);
}
}}
enable
/>
{!hasMasterFile && !currentField.masterFileToUpload && (
<p className="text-xs text-red-500 mt-2">
Uploadez un document avant d&apos;ajouter ce type de champ.
</p>
)}
</div>
<Controller
name="acceptTypes"
control={control}

View File

@ -2,6 +2,7 @@ import logger from '@/utils/logger';
import { useForm, Controller } from 'react-hook-form';
import { useEffect } from 'react';
import SelectChoice from './SelectChoice';
import { getSecureFileUrl } from '@/utils/fileUrl';
import InputTextIcon from './InputTextIcon';
import * as LucideIcons from 'lucide-react';
import Button from './Button';
@ -33,7 +34,22 @@ export default function FormRenderer({
onFormSubmit = (data) => {
alert(JSON.stringify(data, null, 2));
}, // Callback de soumission personnalisé (optionnel)
masterFile = null,
}) {
const resolveMasterFileUrl = (fileValue) => {
if (!fileValue) return null;
if (typeof fileValue !== 'string') return null;
if (fileValue.startsWith('blob:')) return fileValue;
if (fileValue.startsWith('data:')) return fileValue;
if (fileValue.startsWith('http://') || fileValue.startsWith('https://')) {
return fileValue;
}
if (fileValue.startsWith('/api/download?')) return fileValue;
return getSecureFileUrl(fileValue);
};
const masterFileUrl = resolveMasterFileUrl(masterFile);
const {
handleSubmit,
control,
@ -57,8 +73,7 @@ export default function FormRenderer({
const hasFiles = Object.keys(data).some((key) => {
return (
data[key] instanceof FileList ||
(data[key] && data[key][0] instanceof File) ||
(typeof data[key] === 'string' && data[key].startsWith('data:image'))
(data[key] && data[key][0] instanceof File)
);
});
@ -83,29 +98,6 @@ export default function FormRenderer({
formData.append(`files.${key}`, value[i]);
}
}
} else if (
typeof value === 'string' &&
value.startsWith('data:image')
) {
// Gérer les signatures (SVG ou images base64)
if (value.includes('svg+xml')) {
// Gérer les signatures SVG
const svgData = value.split(',')[1];
const svgBlob = new Blob([atob(svgData)], {
type: 'image/svg+xml',
});
formData.append(`files.${key}`, svgBlob, `signature_${key}.svg`);
} else {
// Gérer les images base64 classiques
const byteString = atob(value.split(',')[1]);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
const blob = new Blob([ab], { type: 'image/png' });
formData.append(`files.${key}`, blob, `signature_${key}.png`);
}
} else {
// Gérer les autres types de champs
formData.append(
@ -356,24 +348,39 @@ export default function FormRenderer({
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value } }) => (
<FileUpload
selectionMessage={field.label}
required={field.required}
uploadedFileName={value ? value[0]?.name : null}
onFileSelect={(file) => {
// Créer un objet de type FileList similaire pour la compatibilité
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
onChange(dataTransfer.files);
}}
errorMsg={
errors[field.id]
? field.required
? `${field.label} est requis`
: 'Champ invalide'
: ''
}
/>
masterFileUrl ? (
<div className="w-full bg-neutral border border-gray-200 rounded p-3">
{field.label && (
<p className="text-sm font-medium text-gray-700 mb-2">
{field.label}
</p>
)}
<iframe
src={masterFileUrl}
title={field.label || 'Document'}
className="w-full rounded border border-gray-200 bg-white"
style={{ height: '520px', border: 'none' }}
/>
</div>
) : (
<FileUpload
selectionMessage={field.label}
required={field.required}
uploadedFileName={value ? value[0]?.name : null}
onFileSelect={(file) => {
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
onChange(dataTransfer.files);
}}
errorMsg={
errors[field.id]
? field.required
? `${field.label} est requis`
: 'Champ invalide'
: ''
}
/>
)
)}
/>
)}
@ -406,7 +413,14 @@ export default function FormRenderer({
control={control}
rules={{ required: field.required }}
render={({ field: { onChange, value } }) => (
<div>
<div
className={
masterFile
? 'mt-3 flex justify-end'
: ''
}
>
<div className={masterFile ? 'w-full max-w-xs' : 'w-full'}>
<SignatureField
label={field.label}
required={field.required}
@ -415,6 +429,8 @@ export default function FormRenderer({
backgroundColor={field.backgroundColor || '#ffffff'}
penColor={field.penColor || '#000000'}
penWidth={field.penWidth || 2}
displayWidth={masterFile ? 260 : 400}
displayHeight={masterFile ? 120 : 200}
/>
{errors[field.id] && (
<p className="text-red-500 text-sm mt-1">
@ -423,6 +439,7 @@ export default function FormRenderer({
: 'Champ invalide'}
</p>
)}
</div>
</div>
)}
/>

View File

@ -177,6 +177,8 @@ export default function FormTemplateBuilder({
initialData,
groups,
isEditing,
masterFile = null,
onMasterFileUpload,
}) {
const [formConfig, setFormConfig] = useState({
id: initialData?.id || 0,
@ -186,7 +188,9 @@ export default function FormTemplateBuilder({
});
const [selectedGroups, setSelectedGroups] = useState(
initialData?.groups?.map((g) => g.id) || []
initialData?.groups?.map((g) =>
typeof g === 'object' && g !== null ? g.id : g
) || []
);
const [showAddFieldModal, setShowAddFieldModal] = useState(false);
@ -209,7 +213,11 @@ export default function FormTemplateBuilder({
submitLabel: 'Envoyer',
fields: initialData.formMasterData?.fields || [],
});
setSelectedGroups(initialData.groups?.map((g) => g.id) || []);
setSelectedGroups(
initialData.groups?.map((g) =>
typeof g === 'object' && g !== null ? g.id : g
) || []
);
}
}, [initialData]);
@ -256,6 +264,21 @@ export default function FormTemplateBuilder({
const handleFieldSubmit = (data, currentField, editIndex) => {
const isHeadingType = data.type.startsWith('heading');
const isContentTypeOnly = data.type === 'paragraph' || isHeadingType;
const effectiveMasterFile = masterFile || currentField?.masterFileToUpload;
if (currentField?.masterFileToUpload && onMasterFileUpload) {
onMasterFileUpload(currentField.masterFileToUpload);
}
// Un champ fichier nécessite un document source déjà uploadé.
if (data.type === 'file' && !effectiveMasterFile) {
setSaveMessage({
type: 'error',
text:
'Veuillez d\'abord uploader le document du formulaire avant d\'ajouter un champ fichier.',
});
return;
}
const fieldData = {
...data,
@ -653,7 +676,7 @@ export default function FormTemplateBuilder({
<h3 className="text-lg font-semibold mb-4">Aperçu du formulaire</h3>
<div className="border-2 border-dashed border-gray-300 p-6 rounded">
{formConfig.fields.length > 0 ? (
<FormRenderer formConfig={formConfig} />
<FormRenderer formConfig={formConfig} masterFile={masterFile} />
) : (
<p className="text-gray-500 italic text-center">
Ajoutez des champs pour voir l&apos;aperçu
@ -668,6 +691,8 @@ export default function FormTemplateBuilder({
isOpen={showAddFieldModal}
onClose={() => setShowAddFieldModal(false)}
onSubmit={handleFieldSubmit}
hasMasterFile={Boolean(masterFile)}
onMasterFileUpload={onMasterFileUpload}
editingField={
editingIndex >= 0
? formConfig.fields[editingIndex]

View File

@ -11,6 +11,8 @@ const SignatureField = ({
backgroundColor = '#ffffff',
penColor = '#000000',
penWidth = 2,
displayWidth = 400,
displayHeight = 200,
}) => {
const canvasRef = useRef(null);
const [isDrawing, setIsDrawing] = useState(false);
@ -29,9 +31,6 @@ const SignatureField = ({
// Support High DPI / Retina displays
const devicePixelRatio = window.devicePixelRatio || 1;
const displayWidth = 400;
const displayHeight = 200;
// Ajuster la taille physique du canvas pour la haute résolution
canvas.width = displayWidth * devicePixelRatio;
canvas.height = displayHeight * devicePixelRatio;
@ -56,7 +55,7 @@ const SignatureField = ({
context.lineCap = 'round';
context.lineJoin = 'round';
context.globalCompositeOperation = 'source-over';
}, [backgroundColor, penColor, penWidth]);
}, [backgroundColor, penColor, penWidth, displayWidth, displayHeight]);
useEffect(() => {
initializeCanvas();
@ -226,11 +225,12 @@ const SignatureField = ({
setCurrentPath('');
}
// Notifier le parent du changement avec SVG
// Notifier le parent du changement avec PNG pour garantir
// la compatibilite de rendu cote backend/PDF.
if (onChange) {
const newPaths = [...svgPaths, currentPath].filter((p) => p.length > 0);
const svgData = generateSVG(newPaths);
onChange(svgData);
const canvas = canvasRef.current;
const pngData = canvas ? canvas.toDataURL('image/png') : '';
onChange(pngData);
}
},
[isDrawing, onChange, svgPaths, currentPath]
@ -238,7 +238,7 @@ const SignatureField = ({
// Générer le SVG à partir des paths
const generateSVG = (paths) => {
const svgContent = `<svg width="400" height="200" xmlns="http://www.w3.org/2000/svg">
const svgContent = `<svg width="${displayWidth}" height="${displayHeight}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="${backgroundColor}"/>
${paths
.map(
@ -257,9 +257,6 @@ const SignatureField = ({
const context = canvas.getContext('2d');
// Effacer en tenant compte des dimensions d'affichage
const displayWidth = 400;
const displayHeight = 200;
context.clearRect(0, 0, displayWidth, displayHeight);
context.fillStyle = backgroundColor;
context.fillRect(0, 0, displayWidth, displayHeight);
@ -273,6 +270,14 @@ const SignatureField = ({
}
};
const hintText = readOnly
? isEmpty
? 'Aucune signature'
: 'Signature'
: isEmpty
? 'Signez dans la zone ci-dessus'
: 'Signature capturée';
return (
<div className="signature-field">
{label && (
@ -282,7 +287,7 @@ const SignatureField = ({
</label>
)}
<div className="border border-gray-300 rounded-lg p-4 bg-gray-50">
<div className="border border-gray-300 rounded-lg p-3 bg-gray-50">
<canvas
ref={canvasRef}
className={`border border-gray-200 bg-white rounded touch-none ${
@ -307,16 +312,8 @@ const SignatureField = ({
onTouchEnd={readOnly ? undefined : stopDrawing}
/>
<div className="flex justify-between items-center mt-3">
<div className="text-xs text-gray-500">
{readOnly
? isEmpty
? 'Aucune signature'
: 'Signature'
: isEmpty
? 'Signez dans la zone ci-dessus'
: 'Signature capturée'}
</div>
<div className="flex justify-between items-center mt-2">
<div className="text-xs text-gray-500">{hintText}</div>
{!readOnly && (
<div className="flex gap-2">

View File

@ -34,6 +34,44 @@ export default function DynamicFormsList({
const [formsValidation, setFormsValidation] = useState({});
const fileInputRefs = React.useRef({});
const extractResponses = (data, maxDepth = 8) => {
let current = data;
for (let i = 0; i < maxDepth; i += 1) {
if (!current || typeof current !== 'object') return {};
if (current.responses && typeof current.responses === 'object') {
current = current.responses;
continue;
}
break;
}
if (!current || typeof current !== 'object') return {};
const cleaned = { ...current };
delete cleaned.formId;
delete cleaned.id;
delete cleaned.templateId;
delete cleaned.responses;
return cleaned;
};
const hasLocalCompletion = (templateId) => {
if (formsValidation[templateId] === true) return true;
const localData = formsData[templateId];
if (localData instanceof FormData) return true;
if (localData && typeof localData === 'object') {
return Object.keys(localData).length > 0;
}
const savedResponses = existingResponses[templateId];
return !!(
savedResponses &&
typeof savedResponses === 'object' &&
Object.keys(savedResponses).length > 0
);
};
// Initialiser les données avec les réponses existantes
useEffect(() => {
// Initialisation complète de formsValidation et formsData pour chaque template
@ -90,11 +128,7 @@ 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 ||
(formsData[tpl.id] && Object.keys(formsData[tpl.id]).length > 0) ||
(existingResponses[tpl.id] &&
Object.keys(existingResponses[tpl.id]).length > 0)
(tpl) => tpl.isValidated === true || hasLocalCompletion(tpl.id)
);
onValidationChange(allFormsValid);
@ -113,10 +147,12 @@ export default function DynamicFormsList({
try {
logger.debug('Soumission du formulaire:', { templateId, formData });
const normalizedResponses = extractResponses(formData);
// Sauvegarder les données du formulaire
setFormsData((prev) => ({
...prev,
[templateId]: formData,
[templateId]: normalizedResponses,
}));
// Marquer le formulaire comme complété
@ -145,13 +181,7 @@ export default function DynamicFormsList({
* Vérifie si un formulaire est complété
*/
const isFormCompleted = (templateId) => {
return (
formsValidation[templateId] === true ||
(formsData[templateId] &&
Object.keys(formsData[templateId]).length > 0) ||
(existingResponses[templateId] &&
Object.keys(existingResponses[templateId]).length > 0)
);
return hasLocalCompletion(templateId);
};
/**
@ -229,13 +259,7 @@ export default function DynamicFormsList({
{
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 || hasLocalCompletion(tpl.id);
}).length
}
{' / '}
@ -247,14 +271,10 @@ export default function DynamicFormsList({
// Helper pour état
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)
);
if (isCompletedLocally) return 1; // complété/en attente
return 2; // à compléter/refusé
const isCompletedLocally = hasLocalCompletion(tpl.id);
if (isCompletedLocally) return 1; // complété (en attente de traitement)
if (tpl.isValidated === false) return 2; // refusé
return 3; // à compléter
};
const sortedTemplates = [...schoolFileTemplates].sort((a, b) => {
return getState(a) - getState(b);
@ -268,12 +288,7 @@ export default function DynamicFormsList({
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)
);
const isCompletedLocally = hasLocalCompletion(tpl.id);
// Statut d'affichage
let statusLabel = '';
@ -300,31 +315,17 @@ export default function DynamicFormsList({
: textClass;
canEdit = false;
} else if (isValidated === false) {
if (isCompletedLocally) {
statusLabel = 'Complété';
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';
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';
canEdit = true;
}
statusLabel = 'Refusé';
statusColor = 'red';
icon = <Hourglass 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';
canEdit = true;
} else {
if (isCompletedLocally) {
statusLabel = 'Complété';
@ -405,17 +406,17 @@ export default function DynamicFormsList({
<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) ? (
) : currentTemplate.isValidated === false ? (
<span className="px-2 py-0.5 rounded bg-red-100 text-red-700 text-sm font-semibold">
Refusé
</span>
) : hasLocalCompletion(currentTemplate.id) ? (
<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 className="px-2 py-0.5 rounded bg-gray-100 text-gray-700 text-sm font-semibold">
En attente
</span>
)}
</div>
@ -430,14 +431,10 @@ export default function DynamicFormsList({
// Trouver l'index du template courant dans la liste triée
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)
);
const isCompletedLocally = hasLocalCompletion(tpl.id);
if (isCompletedLocally) return 1;
return 2;
if (tpl.isValidated === false) return 2;
return 3;
};
const sortedTemplates = [...schoolFileTemplates].sort(
(a, b) => getState(a) - getState(b)
@ -469,8 +466,11 @@ export default function DynamicFormsList({
submitLabel:
currentTemplate.formTemplateData?.submitLabel || 'Valider',
}}
masterFile={
currentTemplate.master_file_url || currentTemplate.file || null
}
initialValues={
formsData[currentTemplate.id] ||
extractResponses(formsData[currentTemplate.id]) ||
existingResponses[currentTemplate.id] ||
{}
}

View File

@ -285,6 +285,29 @@ export default function InscriptionFormShared({
throw new Error(`Template avec l'ID ${templateId} non trouvé`);
}
// Aplatit les structures de type { responses: { responses: {...} } }
// pour ne conserver que les reponses de champs (ex: sign).
const extractResponses = (data, maxDepth = 8) => {
let current = data;
for (let i = 0; i < maxDepth; i += 1) {
if (!current || typeof current !== 'object') return {};
if (current.responses && typeof current.responses === 'object') {
current = current.responses;
continue;
}
break;
}
if (!current || typeof current !== 'object') return {};
const cleaned = { ...current };
delete cleaned.formId;
delete cleaned.id;
delete cleaned.templateId;
delete cleaned.responses;
return cleaned;
};
const normalizedResponses = extractResponses(formData);
// Construire la structure complète avec la configuration et les réponses
const formTemplateData = {
id: currentTemplate.id,
@ -300,17 +323,17 @@ export default function InscriptionFormShared({
).map((field) => ({
...field,
...(field.type === 'checkbox'
? { checked: formData[field.id] || false }
? { checked: normalizedResponses[field.id] || false }
: {}),
...(field.type === 'radio' ? { selected: formData[field.id] } : {}),
...(field.type === 'text' ||
field.type === 'textarea' ||
field.type === 'email'
? { value: formData[field.id] || '' }
...(field.type === 'radio'
? { selected: normalizedResponses[field.id] }
: {}),
...(field.id
? { value: normalizedResponses[field.id] ?? field.value ?? '' }
: {}),
})),
submitLabel: currentTemplate.formTemplateData?.submitLabel || 'Valider',
responses: formData,
responses: normalizedResponses,
};
// Sauvegarder les réponses du formulaire via l'API RegistrationSchoolFileTemplate
@ -326,7 +349,7 @@ export default function InscriptionFormShared({
logger.debug("Réponse de l'API:", result);
// Prendre en compte la réponse du back pour mettre à jour les réponses locales
let newResponses = formData;
let newResponses = normalizedResponses;
if (
result &&
result.data &&

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Edit, Trash2, FileText, Star, ChevronDown, Plus } from 'lucide-react';
import Modal from '@/components/Modal';
import FormTemplateBuilder from '@/components/Form/FormTemplateBuilder';
import {
// GET
fetchRegistrationFileGroups,
@ -32,6 +32,7 @@ import CheckBox from '@/components/Form/CheckBox';
import Button from '@/components/Form/Button';
import InputText from '@/components/Form/InputText';
import { getSecureFileUrl } from '@/utils/fileUrl';
import { FE_ADMIN_STRUCTURE_FORM_BUILDER_URL } from '@/utils/Url';
function getItemBgColor(type, selected, forceTheme = false) {
// Colonne gauche : blanc si rien n'est sélectionné, emerald si sélectionné
@ -200,7 +201,7 @@ export default function FilesGroupsManagement({
const [parentFiles, setParentFileMasters] = useState([]);
const [groups, setGroups] = useState([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const router = useRouter();
const [isEditing, setIsEditing] = useState(false);
const [fileToEdit, setFileToEdit] = useState(null);
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
@ -226,10 +227,8 @@ export default function FilesGroupsManagement({
const handleDocDropdownSelect = (type) => {
setIsDocDropdownOpen(false);
if (type === 'formulaire') {
// Ouvre la modale unique en mode création
setIsEditing(false);
setFileToEdit(null);
setIsModalOpen(true);
const groupParam = selectedGroupId ? `?groupId=${selectedGroupId}` : '';
router.push(`${FE_ADMIN_STRUCTURE_FORM_BUILDER_URL}${groupParam}`);
} else if (type === 'formulaire_existant') {
setIsFileUploadPopupOpen(true);
setFileToEdit({});
@ -329,28 +328,29 @@ 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
) {
setFileToEdit(file);
setIsFileUploadPopupOpen(true);
setIsEditing(true);
const isDynamic =
file.formMasterData &&
Array.isArray(file.formMasterData.fields) &&
file.formMasterData.fields.length > 0;
if (isDynamic) {
router.push(`${FE_ADMIN_STRUCTURE_FORM_BUILDER_URL}?id=${file.id}`);
} else {
setIsEditing(true);
setFileToEdit(file);
setIsModalOpen(true);
setIsEditing(true);
setIsFileUploadPopupOpen(true);
}
};
const handleCreateSchoolFileMaster = ({
name,
group_ids,
formMasterData,
file,
}) => {
const handleCreateSchoolFileMaster = (
{
name,
group_ids,
formMasterData,
file,
},
onCreated
) => {
// Toujours envoyer en FormData, même sans fichier
const dataToSend = new FormData();
const jsonData = {
@ -379,12 +379,12 @@ export default function FilesGroupsManagement({
createRegistrationSchoolFileMaster(dataToSend, csrfToken)
.then((data) => {
setSchoolFileMasters((prevFiles) => [...prevFiles, data]);
setIsModalOpen(false);
showNotification(
`Le formulaire "${name}" a été créé avec succès.`,
'success',
'Succès'
);
if (onCreated) onCreated(data);
})
.catch((error) => {
logger.error('Error creating form:', error);
@ -460,7 +460,6 @@ export default function FilesGroupsManagement({
setSchoolFileMasters((prevFichiers) =>
prevFichiers.map((f) => (f.id === id ? data : f))
);
setIsModalOpen(false);
showNotification(
`Le formulaire "${name}" a été modifié avec succès.`,
'success',
@ -495,7 +494,6 @@ export default function FilesGroupsManagement({
setSchoolFileMasters((prevFichiers) =>
prevFichiers.map((f) => (f.id === id ? data : f))
);
setIsModalOpen(false);
showNotification(
`Le formulaire "${name}" a été modifié avec succès.`,
'success',
@ -888,13 +886,6 @@ export default function FilesGroupsManagement({
return count;
};
// Utilitaire pour ouvrir la modale FormTemplateBuilder (création ou édition)
const openFormBuilderModal = (editing = false, initialData = null) => {
setIsEditing(editing);
setFileToEdit(initialData);
setIsModalOpen(true);
};
return (
<div className="w-full">
{/* Aide optionnelle */}
@ -1094,37 +1085,6 @@ export default function FilesGroupsManagement({
</div>
</Modal>
{/* Modals pour création/édition d'un formulaire dynamique */}
<Modal
isOpen={isModalOpen}
setIsOpen={(isOpen) => {
setIsModalOpen(isOpen);
if (!isOpen) {
setFileToEdit(null);
setIsEditing(false);
}
}}
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);
setIsModalOpen(false);
}}
initialData={isEditing ? fileToEdit : undefined}
groups={groups}
isEditing={isEditing}
/>
</div>
</Modal>
{/* Popup pour création/édition d'un formulaire d'école déjà existant */}
<Modal
isOpen={isFileUploadPopupOpen}
@ -1262,11 +1222,13 @@ export default function FilesGroupsManagement({
!fileToEdit?.file
)
return;
handleCreateSchoolFileMaster({
name: fileToEdit.name,
group_ids: fileToEdit.groups,
file: fileToEdit.file,
});
handleCreateSchoolFileMaster(
{
name: fileToEdit.name,
group_ids: fileToEdit.groups,
file: fileToEdit.file,
}
);
setIsFileUploadPopupOpen(false);
setFileToEdit(null);
}}

View File

@ -1,3 +1,4 @@
import { logger } from '@/utils/logger';
import { getToken } from 'next-auth/jwt';
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL;
@ -27,7 +28,6 @@ export default async function handler(req, res) {
const backendRes = await fetch(backendUrl, {
headers: {
Authorization: `Bearer ${token.token}`,
Connection: 'close',
},
});
@ -48,7 +48,8 @@ export default async function handler(req, res) {
const buffer = Buffer.from(await backendRes.arrayBuffer());
return res.send(buffer);
} catch {
} catch (error) {
logger.error('Download proxy error:', error);
return res.status(500).json({ error: 'Erreur lors du téléchargement' });
}
}

View File

@ -103,6 +103,8 @@ export const FE_ADMIN_CLASSES_URL = '/admin/classes';
export const FE_ADMIN_STRUCTURE_URL = '/admin/structure';
export const FE_ADMIN_STRUCTURE_SCHOOLCLASS_MANAGEMENT_URL =
'/admin/structure/SchoolClassManagement';
export const FE_ADMIN_STRUCTURE_FORM_BUILDER_URL =
'/admin/structure/FormBuilder';
//ADMIN/DIRECTORY URL
export const FE_ADMIN_DIRECTORY_URL = '/admin/directory';

View File

@ -10,6 +10,12 @@
*/
export const getSecureFileUrl = (filePath) => {
if (!filePath) return null;
if (typeof filePath !== 'string') return null;
// URL deja proxifiee: la reutiliser telle quelle.
if (filePath.startsWith('/api/download?')) {
return filePath;
}
// Si c'est une URL absolue, extraire le chemin /data/...
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {