feat: Validation document par document [N3WTS-2]

This commit is contained in:
N3WT DE COMPET
2026-02-19 18:53:33 +01:00
parent 3779a47417
commit 8fd1b62ec0
5 changed files with 240 additions and 117 deletions

View File

@ -498,6 +498,7 @@ class RegistrationSchoolFileTemplate(models.Model):
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='school_file_templates', blank=True)
file = models.FileField(null=True,blank=True, upload_to=registration_school_file_upload_to)
formTemplateData = models.JSONField(default=list, blank=True, null=True)
isValidated = models.BooleanField(default=False)
def __str__(self):
return self.name
@ -540,6 +541,7 @@ class RegistrationParentFileTemplate(models.Model):
master = models.ForeignKey(RegistrationParentFileMaster, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
registration_form = models.ForeignKey(RegistrationForm, on_delete=models.CASCADE, related_name='parent_file_templates', blank=True)
file = models.FileField(null=True,blank=True, upload_to=registration_parent_file_upload_to)
isValidated = models.BooleanField(default=False)
def __str__(self):
return self.name

View File

@ -520,7 +520,7 @@ export default function Page({ params: { locale } }) {
</span>
),
onClick: () => {
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
const url = `${FE_ADMIN_SUBSCRIPTIONS_VALIDATE_URL}?studentId=${row.student.id}&firstName=${row.student.first_name}&lastName=${row.student.last_name}&email=${row.student.guardians[0].associated_profile_email}&level=${row.student.level}&sepa_file=${row.sepa_file}&student_file=${row.registration_file}`;
router.push(`${url}`);
},
},

View File

@ -10,6 +10,7 @@ import Loader from '@/components/Loader';
import { useEstablishment } from '@/context/EstablishmentContext';
import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
import { useNotification } from '@/context/NotificationContext';
import { editRegistrationSchoolFileTemplates, editRegistrationParentFileTemplates } from '@/app/actions/registerFileGroupAction';
export default function Page() {
const [isLoadingRefuse, setIsLoadingRefuse] = useState(false);
@ -21,6 +22,7 @@ export default function Page() {
const studentId = searchParams.get('studentId');
const firstName = searchParams.get('firstName');
const lastName = searchParams.get('lastName');
const email = searchParams.get('email');
const level = searchParams.get('level');
const sepa_file =
searchParams.get('sepa_file') === 'null'
@ -86,12 +88,7 @@ export default function Page() {
};
const handleRefuseRF = (reason) => {
setIsLoadingRefuse(true);
const data = {
status: 6, // STATUS_ARCHIVED
notes: reason,
};
const handleRefuseRF = (data) => {
const formData = new FormData();
formData.append('data', JSON.stringify(data));
editRegisterForm(studentId, formData, csrfToken)
@ -108,6 +105,37 @@ export default function Page() {
});
};
// Validation/refus d'un document individuel (hors fiche élève)
const handleValidateOrRefuseDoc = ({ templateId, type, validated, csrfToken }) => {
if (!templateId) return;
let editFn = null;
if (type === 'school') {
editFn = editRegistrationSchoolFileTemplates;
} else if (type === 'parent') {
editFn = editRegistrationParentFileTemplates;
}
if (!editFn) return;
const updateData = new FormData();
updateData.append('data', JSON.stringify({ isValidated: validated }));
editFn(templateId, updateData, csrfToken)
.then((response) => {
logger.debug(`Document ${validated ? 'validé' : 'refusé'} (type: ${type}, id: ${templateId})`, response);
showNotification(
`Le document a bien été ${validated ? 'validé' : 'refusé'}.`,
'success',
'Succès'
);
})
.catch((error) => {
logger.error('Erreur lors de la validation/refus du document:', error);
showNotification(
`Erreur lors de la ${validated ? 'validation' : 'refus'} du document.`,
'error',
'Erreur'
);
});
};
if (isLoading) {
return <Loader />;
}
@ -117,12 +145,15 @@ export default function Page() {
studentId={studentId}
firstName={firstName}
lastName={lastName}
email={email}
sepa_file={sepa_file}
student_file={student_file}
onAccept={handleAcceptRF}
classes={classes}
onRefuse={handleRefuseRF}
isLoadingRefuse={isLoadingRefuse}
handleValidateOrRefuseDoc={handleValidateOrRefuseDoc}
csrfToken={csrfToken}
/>
);
}

View File

@ -1,69 +0,0 @@
import React, { useState } from 'react';
import Textarea from '@/components/Textarea';
import Button from '@/components/Form/Button';
/**
* Composant pour afficher le textarea de refus et le bouton d'action
* @param {function} onRefuse - callback appelée avec la raison du refus
* @param {boolean} isLoading - état de chargement
*/
const RefuseSubscription = ({ onRefuse, isLoading }) => {
const [reason, setReason] = useState('');
const [showTextarea, setShowTextarea] = useState(false);
const handleRefuseClick = () => {
setShowTextarea((prev) => !prev);
};
const handleSubmit = (e) => {
e.preventDefault();
if (reason.trim()) {
onRefuse(reason);
setShowTextarea(false);
setReason('');
}
};
return (
<div className="flex flex-col items-stretch justify-center h-full">
{!showTextarea ? (
<Button
text="Refuser le dossier d'inscription"
onClick={handleRefuseClick}
className="bg-red-500 hover:bg-red-700 text-white min-w-[220px] h-10"
type="button"
/>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-2 mt-2">
<div className="flex items-start gap-2">
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Ex : Réception de dossier trop tardive"
rows={3}
className="w-full"
required
/>
<button
type="button"
onClick={handleRefuseClick}
className="text-sm text-gray-500 hover:text-red-600 underline mt-1"
tabIndex={-1}
>
Annuler
</button>
</div>
<Button
text={isLoading ? 'Refus en cours...' : 'Confirmer le refus'}
type="submit"
className="bg-red-600 hover:bg-red-800 text-white w-full"
style={{ height: '40px' }}
disabled={isLoading || !reason.trim()}
/>
</form>
)}
</div>
);
};
export default RefuseSubscription;

View File

@ -1,5 +1,6 @@
'use client';
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';
@ -8,27 +9,34 @@ import {
fetchParentFileTemplatesFromRegistrationFiles,
} from '@/app/actions/subscriptionAction';
import logger from '@/utils/logger';
import { School, CheckCircle, Hourglass, FileText } from 'lucide-react';
import { School, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
import Button from '@/components/Form/Button';
import RefuseSubscription from './RefuseSubscription';
export default function ValidateSubscription({
studentId,
firstName,
email,
lastName,
sepa_file,
student_file,
onAccept,
onRefuse,
classes,
onRefuse, // callback pour refus
isLoadingRefuse = false, // état de chargement refus
handleValidateOrRefuseDoc,
csrfToken,
}) {
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
const [parentFileTemplates, setParentFileTemplates] = useState([]);
const [currentTemplateIndex, setCurrentTemplateIndex] = useState(0);
const [mergeDocuments, setMergeDocuments] = useState(false);
const [isPageValid, setIsPageValid] = useState(false);
// Pour la validation/refus des documents
const [docStatuses, setDocStatuses] = useState({}); // {index: 'accepted'|'refused'}
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 [formData, setFormData] = useState({
associated_class: null,
@ -91,14 +99,27 @@ export default function ValidateSubscription({
},
status: 5,
fusionParam: mergeDocuments,
notes: 'Dossier validé',
};
onAccept(data);
} else {
logger.warn('Aucune classe sélectionnée.');
}
};
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');
const data = {
status: 2,
notes,
};
if (onRefuse) {
onRefuse(data);
}
};
const onChange = (field, value) => {
setFormData((prev) => ({
...prev,
@ -128,6 +149,17 @@ export default function ValidateSubscription({
]
: []),
];
// Récupère la liste des documents refusés (inclut la fiche élève si refusée)
const refusedDocs = allTemplates
.map((doc, idx) => ({ ...doc, idx }))
.filter((doc, idx) => docStatuses[idx] === 'refused');
// Récupère la liste des documents à cocher (inclut la fiche élève)
const docIndexes = allTemplates.map((_, idx) => idx);
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 (
@ -166,7 +198,7 @@ export default function ValidateSubscription({
</div>
{/* Colonne droite : Liste des documents, Option de fusion, Affectation, Refus */}
<div className="w-1/4 flex flex-col flex-1 gap-4">
<div className="w-1/4 flex flex-col flex-1 gap-4 h-full">
{/* Liste des documents */}
<div className="flex-1 bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200 overflow-y-auto">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
@ -190,58 +222,185 @@ export default function ValidateSubscription({
<FileText className="w-5 h-5 text-green-600" />
)}
</span>
{template.name}
<span className="flex-1">{template.name}</span>
{/* 3 boutons côte à côte : À traiter / Validé / Refusé (pour tous les documents, y compris fiche élève) */}
<span className="ml-2 flex gap-1">
<button
type="button"
className={`px-2 py-1 rounded-full text-xs font-medium border transition-colors focus:outline-none focus:ring-2 focus:ring-blue-400
${docStatuses[index] === undefined ? 'bg-gray-300 text-gray-700 border-gray-400' : 'bg-white text-gray-500 border-gray-300'}`}
aria-pressed={docStatuses[index] === undefined}
onClick={e => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: undefined }));
}}
>À traiter</button>
<button
type="button"
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 => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'accepted' }));
// Appel API pour valider le document (hors fiche élève)
if (index > 0 && handleValidateOrRefuseDoc) {
// index 0 = fiche élève, ensuite school puis parent puis SEPA
let template = null;
let type = null;
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];
type = 'parent';
}
if (template && template.id) {
handleValidateOrRefuseDoc({
templateId: template.id,
type,
validated: true,
csrfToken,
});
}
}
}}
>
<span className="text-lg"></span> Validé
</button>
<button
type="button"
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 => {
e.stopPropagation();
setDocStatuses(s => ({ ...s, [index]: 'refused' }));
// Appel API pour refuser le document (hors fiche élève)
if (index > 0 && handleValidateOrRefuseDoc) {
let template = null;
let type = null;
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];
type = 'parent';
}
if (template && template.id) {
handleValidateOrRefuseDoc({
templateId: template.id,
type,
validated: false,
csrfToken,
});
}
}
}}
>
<span className="text-lg"></span> Refusé
</button>
</span>
</li>
))}
</ul>
</div>
{/* Option de fusion */}
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex items-center justify-center mb-2 min-h-[70px]">
<ToggleSwitch
label="Fusionner les documents"
checked={mergeDocuments}
onChange={handleToggleMergeDocuments}
className="ml-0"
/>
</div>
{/* Affectation à une classe */}
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex items-center gap-4">
<div className="flex-1 flex flex-col">
<SelectChoice
name="associated_class"
label="Liste des classes"
placeHolder="Sélectionner une classe"
selected={formData.associated_class || ''}
callback={(e) => onChange('associated_class', e.target.value)}
choices={classes.map((classe) => ({
value: classe.id,
label: classe.atmosphere_name,
}))}
required
className="mt-2"
/>
{/* Nouvelle section Options de validation : carte unique, sélecteur de classe (ligne 1), toggle fusion (ligne 2 aligné à droite) */}
{allChecked && allValidated && (
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 flex flex-col gap-4">
<div>
<SelectChoice
name="associated_class"
label="Liste des classes"
placeHolder="Sélectionner une classe"
selected={formData.associated_class || ''}
callback={(e) => onChange('associated_class', e.target.value)}
choices={classes.map((classe) => ({
value: classe.id,
label: classe.atmosphere_name,
}))}
required
className="w-full"
/>
</div>
<div className="flex justify-end items-center mt-2">
<ToggleSwitch
label="Fusionner les documents"
checked={mergeDocuments}
onChange={handleToggleMergeDocuments}
className="ml-0"
/>
</div>
</div>
</div>
)}
{/* Boutons Valider/Refuser en bas, centrés */}
<div className="mt-auto flex justify-center gap-4 py-4">
<div className="mt-auto py-4">
<Button
text="Valider le dossier d'inscription"
onClick={(e) => {
text="Soumettre"
onClick={e => {
e.preventDefault();
handleAssignClass();
// 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
if (allChecked && hasRefused) {
setShowRefusedPopup(true);
return;
}
// 3. Si tous cochés et tous validés mais pas de classe sélectionnée : bouton désactivé
// 4. Si tous cochés, tous validés et classe sélectionnée : popup de validation finale
if (allChecked && allValidated && formData.associated_class) {
setShowFinalValidationPopup(true);
}
}}
primary
className={`min-w-[220px] h-10 rounded-md shadow-sm focus:outline-none ${
!isPageValid
className={`w-full h-12 rounded-md shadow-sm focus:outline-none ${
!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={!isPageValid}
disabled={
!allChecked || (allChecked && allValidated && !formData.associated_class)
}
/>
<RefuseSubscription onRefuse={onRefuse} isLoading={isLoadingRefuse} />
</div>
{/* Popup de confirmation si refus */}
<Popup
isOpen={showRefusedPopup}
onCancel={() => setShowRefusedPopup(false)}
onConfirm={() => {
setShowRefusedPopup(false);
handleRefuseDossier();
}}
message={
<span>
{`Le dossier d'inscription de ${firstName} ${lastName} va être refusé. Un email sera envoyé au responsable à l'adresse : `}
<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 => (
<li key={doc.idx}>{doc.name}</li>
))}
</ul>
</span>
}
/>
{/* Popup de confirmation finale si tous validés et classe sélectionnée */}
<Popup
isOpen={showFinalValidationPopup}
onCancel={() => setShowFinalValidationPopup(false)}
onConfirm={() => {
setShowFinalValidationPopup(false);
handleAssignClass();
}}
message={
<span>
{`Le dossier d'inscription de ${lastName} ${firstName} va être validé et l'élève affecté à la classe sélectionnée.`}
</span>
}
/>
</div>
</div>
</div>