feat: Ajout des Bundles de fichiers [#24]

This commit is contained in:
Luc SORIGNET
2025-02-10 18:35:24 +01:00
parent fb7fbaf839
commit ffc6ce8de8
25 changed files with 1736 additions and 743 deletions

View File

@ -0,0 +1,50 @@
import React, { useState } from 'react';
import { Upload } from 'lucide-react';
export default function DraggableFileUpload({ fileName, onFileSelect }) {
const [dragActive, setDragActive] = useState(false);
const handleDragOver = (event) => {
event.preventDefault();
setDragActive(true);
};
const handleDragLeave = () => {
setDragActive(false);
};
const handleFileChosen = (selectedFile) => {
onFileSelect && onFileSelect(selectedFile);
};
const handleDrop = (event) => {
event.preventDefault();
setDragActive(false);
const droppedFile = event.dataTransfer.files[0];
handleFileChosen(droppedFile);
};
const handleFileChange = (event) => {
const selectedFile = event.target.files[0];
handleFileChosen(selectedFile);
};
return (
<div>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`border-2 border-dashed p-8 rounded-md ${dragActive ? 'border-blue-500' : 'border-gray-300'} flex flex-col items-center justify-center`}
style={{ height: '200px' }}
>
<input type="file" onChange={handleFileChange} className="hidden" id="fileInput" />
<label htmlFor="fileInput" className="cursor-pointer flex flex-col items-center">
<Upload size={48} className="text-gray-400 mb-2" />
<p className="text-center">{fileName || 'Glissez et déposez un fichier ici ou cliquez ici pour sélectionner un fichier'}</p>
</label>
</div>
</div>
);
}

View File

@ -0,0 +1,98 @@
import React, { useState, useEffect } from 'react';
import ToggleSwitch from '@/components/ToggleSwitch'; // Import du composant ToggleSwitch
import DraggableFileUpload from './DraggableFileUpload';
import { fetchRegistrationFileGroups } from '@/app/lib/registerFileGroupAction';
export default function FileUpload({ onFileUpload, fileToEdit = null }) {
const [fileName, setFileName] = useState('');
const [file, setFile] = useState(null);
const [isRequired, setIsRequired] = useState(false); // État pour le toggle isRequired
const [order, setOrder] = useState(0);
const [groups, setGroups] = useState([]);
const [selectedGroup, setSelectedGroup] = useState('');
useEffect(() => {
fetchRegistrationFileGroups().then(data => setGroups(data));
if (fileToEdit) {
setFileName(fileToEdit.name || '');
setIsRequired(fileToEdit.is_required || false);
setOrder(fileToEdit.fusion_order || 0);
setSelectedGroup(fileToEdit.group_id || '');
}
}, [fileToEdit]);
const handleFileNameChange = (event) => {
setFileName(event.target.value);
};
const handleUpload = () => {
onFileUpload({
file,
name: fileName,
is_required: isRequired,
order: parseInt(order, 10),
groupId: selectedGroup || null
});
setFile(null);
setFileName('');
setIsRequired(false);
setOrder(0);
setSelectedGroup('');
};
return (
<div>
<DraggableFileUpload
fileName={fileName}
onFileSelect={(selectedFile) => {
setFile(selectedFile);
setFileName(selectedFile.name.replace(/\.[^/.]+$/, ""));
}}
/>
<div className="flex mt-2">
<input
type="text"
placeholder="Nom du fichier"
value={fileName}
onChange={handleFileNameChange}
className="flex-grow p-2 border border-gray-200 rounded-md"
/>
<input
type="number"
value={order}
onChange={(e) => setOrder(e.target.value)}
placeholder="Ordre de fusion"
className="p-2 border border-gray-200 rounded-md ml-2 w-20"
/>
<button
onClick={handleUpload}
className={`p-2 rounded-md shadow transition duration-200 ml-2 ${fileName !== "" ? 'bg-emerald-600 text-white hover:bg-emerald-900' : 'bg-gray-300 text-gray-500 cursor-not-allowed'}`}
disabled={fileName === ""}
>
Ajouter
</button>
</div>
<div className="flex items-center mt-4">
<ToggleSwitch
label="Fichier à remplir obligatoirement"
checked={isRequired}
onChange={() => setIsRequired(!isRequired)}
/>
</div>
<div className="mt-4">
<label className="block mb-2">Groupe</label>
<select
value={selectedGroup}
onChange={(e) => setSelectedGroup(e.target.value)}
className="w-full border rounded p-2"
>
<option value="">Aucun groupe</option>
{groups.map(group => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
</div>
</div>
);
}

View File

@ -9,7 +9,7 @@ import DiscountsSection from '@/components/Structure/Tarification/DiscountsSecti
import SectionTitle from '@/components/SectionTitle';
import ProgressStep from '@/components/ProgressStep';
const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, registrationFees, tuitionFees, onSubmit, currentStep }) => {
const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, registrationFees, tuitionFees, onSubmit, currentStep, groups }) => {
const [formData, setFormData] = useState({
studentLastName: '',
studentFirstName: '',
@ -21,7 +21,8 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
selectedRegistrationDiscounts: [],
selectedRegistrationFees: registrationFees.map(fee => fee.id),
selectedTuitionDiscounts: [],
selectedTuitionFees: []
selectedTuitionFees: [],
selectedFileGroup: null // Ajout du groupe de fichiers sélectionné
});
const [step, setStep] = useState(currentStep || 1);
@ -35,10 +36,11 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
2: 'Nouveau Responsable',
3: "Frais d'inscription",
4: 'Frais de scolarité',
5: 'Récapitulatif'
5: 'Documents requis',
6: 'Récapitulatif'
};
const steps = ['Élève', 'Responsable', 'Inscription', 'Scolarité', 'Récap'];
const steps = ['Élève', 'Responsable', 'Inscription', 'Scolarité', 'Documents', 'Récap'];
const isStep1Valid = formData.studentLastName && formData.studentFirstName;
const isStep2Valid = (
@ -47,7 +49,8 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
);
const isStep3Valid = formData.selectedRegistrationFees.length > 0;
const isStep4Valid = formData.selectedTuitionFees.length > 0;
const isStep5Valid = isStep1Valid && isStep2Valid && isStep3Valid && isStep4Valid;
const isStep5Valid = formData.selectedFileGroup !== null;
const isStep6Valid = isStep1Valid && isStep2Valid && isStep3Valid && isStep4Valid && isStep5Valid;
const isStepValid = (stepNumber) => {
switch (stepNumber) {
@ -61,6 +64,8 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
return isStep4Valid;
case 5:
return isStep5Valid;
case 6:
return isStep6Valid;
default:
return false;
}
@ -464,6 +469,44 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
</div>
)}
{step === 5 && (
<div>
{groups.length > 0 ? (
<div className="space-y-4">
<h3 className="font-bold">Sélectionnez un groupe de documents</h3>
{groups.map((group) => (
<div key={group.id} className="flex items-center space-x-3">
<input
type="radio"
name="fileGroup"
value={group.id}
checked={formData.selectedFileGroup === group.id}
onChange={(e) => setFormData({
...formData,
selectedFileGroup: parseInt(e.target.value)
})}
className="form-radio h-4 w-4 text-emerald-600"
/>
<label className="text-gray-900">
{group.name}
{group.description && (
<span className="text-sm text-gray-500 ml-2">
({group.description})
</span>
)}
</label>
</div>
))}
</div>
) : (
<p className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<strong className="font-bold">Attention!</strong>
<span className="block sm:inline"> Aucun groupe de documents n'a été créé.</span>
</p>
)}
</div>
)}
{step === steps.length && (
<div>
<div className="space-y-4">
@ -553,7 +596,8 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
(step === 1 && !isStep1Valid) ||
(step === 2 && !isStep2Valid) ||
(step === 3 && !isStep3Valid) ||
(step === 4 && !isStep4Valid)
(step === 4 && !isStep4Valid) ||
(step === 5 && !isStep5Valid)
)
? "bg-gray-300 text-gray-700 cursor-not-allowed"
: "bg-emerald-500 text-white hover:bg-emerald-600"
@ -563,7 +607,8 @@ const InscriptionForm = ( { students, registrationDiscounts, tuitionDiscounts, r
(step === 1 && !isStep1Valid) ||
(step === 2 && !isStep2Valid) ||
(step === 3 && !isStep3Valid) ||
(step === 4 && !isStep4Valid)
(step === 4 && !isStep4Valid) ||
(step === 5 && !isStep5Valid)
)
}
primary

View File

@ -8,9 +8,10 @@ import Button from '@/components/Button';
import DjangoCSRFToken from '@/components/DjangoCSRFToken';
import Table from '@/components/Table';
import { fetchRegisterFormFileTemplate, createRegistrationFormFile, fetchRegisterForm, deleteRegisterFormFile } from '@/app/lib/subscriptionAction';
import { fetchRegistrationFileFromGroup } from '@/app/lib/registerFileGroupAction';
import { Download, Upload, Trash2, Eye } from 'lucide-react';
import { BASE_URL } from '@/utils/Url';
import DraggableFileUpload from '@/app/[locale]/admin/subscriptions/components/DraggableFileUpload';
import DraggableFileUpload from '@/components/DraggableFileUpload';
import Modal from '@/components/Modal';
import FileStatusLabel from '@/components/FileStatusLabel';
@ -57,6 +58,7 @@ export default function InscriptionFormShared({
// États pour la gestion des fichiers
const [uploadedFiles, setUploadedFiles] = useState([]);
const [fileTemplates, setFileTemplates] = useState([]);
const [fileGroup, setFileGroup] = useState(null);
const [fileName, setFileName] = useState("");
const [file, setFile] = useState("");
const [showUploadModal, setShowUploadModal] = useState(false);
@ -83,15 +85,21 @@ export default function InscriptionFormShared({
});
setGuardians(data?.student?.guardians || []);
setUploadedFiles(data.registration_files || []);
setFileGroup(data.fileGroup || null);
});
fetchRegisterFormFileTemplate().then((data) => {
setFileTemplates(data);
});
setIsLoading(false);
}
}, [studentId]);
useEffect(() => {
if(fileGroup){
fetchRegistrationFileFromGroup(fileGroup).then((data) => {
setFileTemplates(data);
});
}
}, [fileGroup]);
// Fonctions de gestion du formulaire et des fichiers
const updateFormField = (field, value) => {
setFormData(prev => ({...prev, [field]: value}));

View File

@ -0,0 +1,56 @@
import React, { useState, useEffect } from 'react';
export default function RegistrationFileGroupForm({ onSubmit, initialData }) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
useEffect(() => {
if (initialData) {
setName(initialData.name);
setDescription(initialData.description);
}
}, [initialData]);
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ name, description });
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nom du groupe
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex justify-end">
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{initialData ? 'Modifier le groupe' : 'Créer le groupe'}
</button>
</div>
</form>
);
}

View File

@ -0,0 +1,21 @@
import React, { useEffect, useState } from 'react';
import { fetchRegistrationFileGroups } from '@/app/lib/registerFileGroupAction';
export default function RegistrationFileGroupList() {
const [groups, setGroups] = useState([]);
useEffect(() => {
fetchRegistrationFileGroups().then(data => setGroups(data));
}, []);
return (
<div>
<h2>Groupes de fichiers d'inscription</h2>
<ul>
{groups.map(group => (
<li key={group.id}>{group.name}</li>
))}
</ul>
</div>
);
}

View File

@ -0,0 +1,302 @@
import React, { useState, useEffect } from 'react';
import { Plus, Download, Edit, Trash2, FolderPlus } from 'lucide-react';
import Modal from '@/components/Modal';
import Table from '@/components/Table';
import FileUpload from '@/components/FileUpload';
import { formatDate } from '@/utils/Date';
import { BASE_URL } from '@/utils/Url';
import {
fetchRegisterFormFileTemplate,
createRegistrationFormFileTemplate,
editRegistrationFormFileTemplate,
deleteRegisterFormFileTemplate,
getRegisterFormFileTemplate
} from '@/app/lib/subscriptionAction';
import {
fetchRegistrationFileGroups,
createRegistrationFileGroup,
deleteRegistrationFileGroup,
editRegistrationFileGroup
} from '@/app/lib/registerFileGroupAction';
import RegistrationFileGroupForm from '@/components/RegistrationFileGroupForm';
export default function FilesManagement({ csrfToken }) {
const [fichiers, setFichiers] = useState([]);
const [groups, setGroups] = useState([]);
const [selectedGroup, setSelectedGroup] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [fileToEdit, setFileToEdit] = useState(null);
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
const [groupToEdit, setGroupToEdit] = useState(null);
// Fonction pour transformer les données des fichiers avec les informations complètes du groupe
const transformFileData = (file, groups) => {
if (!file.group) return file;
const groupInfo = groups.find(g => g.id === file.group);
return {
...file,
group: groupInfo || { id: file.group, name: 'Groupe inconnu' }
};
};
useEffect(() => {
Promise.all([
fetchRegisterFormFileTemplate(),
fetchRegistrationFileGroups()
]).then(([filesData, groupsData]) => {
setGroups(groupsData);
// Sélectionner automatiquement le premier groupe s'il existe
if (groupsData.length > 0) {
setSelectedGroup(groupsData[0].id.toString());
}
// Transformer chaque fichier pour inclure les informations complètes du groupe
const transformedFiles = filesData.map(file => transformFileData(file, groupsData));
setFichiers(transformedFiles);
}).catch(err => {
console.log(err.message);
});
}, []);
const handleFileDelete = (fileId) => {
deleteRegisterFormFileTemplate(fileId, csrfToken)
.then(response => {
if (response.ok) {
setFichiers(fichiers.filter(fichier => fichier.id !== fileId));
alert('Fichier supprimé avec succès.');
} else {
alert('Erreur lors de la suppression du fichier.');
}
})
.catch(error => {
console.error('Error deleting file:', error);
alert('Erreur lors de la suppression du fichier.');
});
};
const handleFileEdit = (file) => {
setIsEditing(true);
setFileToEdit(file);
setIsModalOpen(true);
};
const handleFileUpload = ({file, name, is_required, order, groupId}) => {
if (!name) {
alert('Veuillez entrer un nom de fichier.');
return;
}
const formData = new FormData();
if(file) {
formData.append('file', file);
}
formData.append('name', name);
formData.append('is_required', is_required);
formData.append('order', order);
// Modification ici : vérifier si groupId existe et n'est pas vide
if (groupId && groupId !== '') {
formData.append('group', groupId); // Notez que le nom du champ est 'group' et non 'group_id'
}
if (isEditing && fileToEdit) {
editRegistrationFormFileTemplate(fileToEdit.id, formData, csrfToken)
.then(data => {
// Transformer le fichier mis à jour avec les informations du groupe
const transformedFile = transformFileData(data, groups);
setFichiers(prevFichiers =>
prevFichiers.map(f => f.id === fileToEdit.id ? transformedFile : f)
);
setIsModalOpen(false);
setFileToEdit(null);
setIsEditing(false);
})
.catch(error => {
console.error('Error editing file:', error);
alert('Erreur lors de la modification du fichier');
});
} else {
createRegistrationFormFileTemplate(formData, csrfToken)
.then(data => {
// Transformer le nouveau fichier avec les informations du groupe
const transformedFile = transformFileData(data, groups);
setFichiers(prevFiles => [...prevFiles, transformedFile]);
setIsModalOpen(false);
})
.catch(error => {
console.error('Error uploading file:', error);
});
}
};
const handleGroupSubmit = async (groupData) => {
try {
if (groupToEdit) {
const updatedGroup = await editRegistrationFileGroup(groupToEdit.id, groupData, csrfToken);
setGroups(groups.map(group => group.id === groupToEdit.id ? updatedGroup : group));
setGroupToEdit(null);
} else {
const newGroup = await createRegistrationFileGroup(groupData, csrfToken);
setGroups([...groups, newGroup]);
}
setIsGroupModalOpen(false);
} catch (error) {
console.error('Error handling group:', error);
alert('Erreur lors de l\'opération sur le groupe');
}
};
const handleGroupEdit = (group) => {
setGroupToEdit(group);
setIsGroupModalOpen(true);
};
const handleGroupDelete = (groupId) => {
// Vérifier si des fichiers utilisent ce groupe
const filesInGroup = fichiers.filter(file => file.group && file.group.id === groupId);
if (filesInGroup.length > 0) {
alert('Impossible de supprimer ce groupe car il contient des fichiers. Veuillez d\'abord retirer tous les fichiers de ce groupe.');
return;
}
if (window.confirm('Êtes-vous sûr de vouloir supprimer ce groupe ?')) {
deleteRegistrationFileGroup(groupId, csrfToken)
.then((response) => {
if (response.status === 409) {
throw new Error('Ce groupe est lié à des inscriptions existantes.');
}
if (!response.ok) {
throw new Error('Erreur lors de la suppression du groupe.');
}
setGroups(groups.filter(group => group.id !== groupId));
alert('Groupe supprimé avec succès.');
})
.catch(error => {
console.error('Error deleting group:', error);
alert(error.message || 'Erreur lors de la suppression du groupe. Vérifiez qu\'aucune inscription n\'utilise ce groupe.');
});
}
};
// Ajouter cette fonction de filtrage
const filteredFiles = fichiers.filter(file => {
if (!selectedGroup) return true;
return file.group && file.group.id === parseInt(selectedGroup);
});
const columnsFiles = [
{ name: 'Nom du fichier', transform: (row) => row.name },
{ name: 'Groupe', transform: (row) => row.group ? row.group.name : 'Aucun' },
{ name: 'Date de création', transform: (row) => formatDate(new Date (row.date_added),"DD/MM/YYYY hh:mm:ss") },
{ name: 'Fichier Obligatoire', transform: (row) => row.is_required ? 'Oui' : 'Non' },
{ name: 'Ordre de fusion', transform: (row) => row.order },
{ name: 'Actions', transform: (row) => (
<div className="flex items-center justify-center gap-2">
{row.file && (
<a href={`${BASE_URL}${row.file}`} target='_blank' className="text-blue-500 hover:text-blue-700">
<Download size={16} />
</a>
)}
<button onClick={() => handleFileEdit(row)} className="text-blue-500 hover:text-blue-700">
<Edit size={16} />
</button>
<button onClick={() => handleFileDelete(row.id)} className="text-red-500 hover:text-red-700">
<Trash2 size={16} />
</button>
</div>
)}
];
const columnsGroups = [
{ name: 'Nom du groupe', transform: (row) => row.name },
{ name: 'Description', transform: (row) => row.description },
{ name: 'Actions', transform: (row) => (
<div className="flex items-center justify-center gap-2">
<button onClick={() => handleGroupEdit(row)} className="text-blue-500 hover:text-blue-700">
<Edit size={16} />
</button>
<button onClick={() => handleGroupDelete(row.id)} className="text-red-500 hover:text-red-700">
<Trash2 size={16} />
</button>
</div>
)}
];
return (
<div>
<Modal
isOpen={isModalOpen}
setIsOpen={setIsModalOpen}
title={isEditing ? 'Modifier un fichier' : 'Ajouter un fichier'}
ContentComponent={() => (
<FileUpload
onFileUpload={handleFileUpload}
fileToEdit={fileToEdit}
/>
)}
/>
<Modal
isOpen={isGroupModalOpen}
setIsOpen={setIsGroupModalOpen}
title={groupToEdit ? "Modifier le groupe" : "Ajouter un groupe de fichiers"}
ContentComponent={() => (
<RegistrationFileGroupForm
onSubmit={handleGroupSubmit}
initialData={groupToEdit}
/>
)}
/>
<div className="mt-8 mb-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Groupes de fichiers</h2>
<button
onClick={() => setIsGroupModalOpen(true)}
className="flex items-center bg-blue-600 text-white p-2 rounded-full shadow hover:bg-blue-900 transition duration-200"
>
<FolderPlus className="w-5 h-5" />
</button>
</div>
<Table
data={groups}
columns={columnsGroups}
itemsPerPage={5}
currentPage={1}
totalPages={Math.ceil(groups.length / 5)}
/>
</div>
{groups.length > 0 && (
<div className="mt-8">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">Fichiers</h2>
<div className="flex items-center gap-4">
<select
className="border rounded p-2"
value={selectedGroup || ''}
onChange={(e) => setSelectedGroup(e.target.value)}
>
<option value="">Tous les groupes</option>
{groups.map(group => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
<button
onClick={() => { setIsModalOpen(true); setIsEditing(false); }}
className="flex items-center bg-emerald-600 text-white p-2 rounded-full shadow hover:bg-emerald-900 transition duration-200"
>
<Plus className="w-5 h-5" />
</button>
</div>
</div>
<Table
data={filteredFiles}
columns={columnsFiles}
itemsPerPage={10}
currentPage={1}
totalPages={Math.ceil(filteredFiles.length / 10)}
/>
</div>
)}
</div>
);
}