feat: Ajout bouton de refus de dossier avec zone de saisie de motif [N3WTS-2]

This commit is contained in:
N3WT DE COMPET
2026-02-16 17:54:20 +01:00
parent 05c68ebfaa
commit 3779a47417
5 changed files with 223 additions and 44 deletions

View File

@ -2,6 +2,7 @@
import React, { useState, useEffect } from 'react';
import Table from '@/components/Table';
import Tab from '@/components/Tab';
import Textarea from '@/components/Textarea';
import { useTranslations } from 'next-intl';
import StatusLabel from '@/components/StatusLabel';
import Popup from '@/components/Popup';
@ -96,6 +97,38 @@ export default function Page({ params: { locale } }) {
const [isSepaUploadModalOpen, setIsSepaUploadModalOpen] = useState(false);
const [selectedRowForUpload, setSelectedRowForUpload] = useState(null);
// Refus popup state
const [isRefusePopupOpen, setIsRefusePopupOpen] = useState(false);
const [refuseReason, setRefuseReason] = useState('');
const [rowToRefuse, setRowToRefuse] = useState(null);
// Ouvre la popup de refus
const openRefusePopup = (row) => {
setRowToRefuse(row);
setRefuseReason('');
setIsRefusePopupOpen(true);
};
// Valide le refus
const handleRefuse = () => {
if (!refuseReason.trim()) {
showNotification('Merci de préciser la raison du refus.', 'error', 'Erreur');
return;
}
editRegisterForm(
rowToRefuse.student.id,
{ status: RegistrationFormStatus.STATUS_ARCHIVED, notes: refuseReason },
csrfToken
)
.then(() => {
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
setReloadFetch(true);
setIsRefusePopupOpen(false);
})
.catch(() => {
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
});
};
const csrfToken = useCsrfToken();
const router = useRouter();
const { selectedEstablishmentId, profileRole } = useEstablishment();
@ -491,6 +524,14 @@ export default function Page({ params: { locale } }) {
router.push(`${url}`);
},
},
{
icon: (
<span title="Refuser le dossier">
<Archive className="w-5 h-5 text-red-500 hover:text-red-700" />
</span>
),
onClick: () => openRefusePopup(row),
},
],
// Etat "A relancer" - NON TESTE
[RegistrationFormStatus.STATUS_TO_FOLLOW_UP]: [
@ -852,6 +893,25 @@ export default function Page({ params: { locale } }) {
onCancel={() => setConfirmPopupVisible(false)}
/>
{/* Popup de refus de dossier */}
<Popup
isOpen={isRefusePopupOpen}
message={
<div>
<div className="mb-2 font-semibold">Veuillez indiquer la raison du refus :</div>
<Textarea
value={refuseReason}
onChange={(e) => setRefuseReason(e.target.value)}
placeholder="Ex : Réception de dossier trop tardive"
rows={3}
className="w-full"
/>
</div>
}
onConfirm={handleRefuse}
onCancel={() => setIsRefusePopupOpen(false)}
/>
{isSepaUploadModalOpen && (
<Modal
isOpen={isSepaUploadModalOpen}

View File

@ -12,6 +12,7 @@ import { FE_ADMIN_SUBSCRIPTIONS_URL } from '@/utils/Url';
import { useNotification } from '@/context/NotificationContext';
export default function Page() {
const [isLoadingRefuse, setIsLoadingRefuse] = useState(false);
const searchParams = useSearchParams();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
@ -84,6 +85,29 @@ export default function Page() {
});
};
const handleRefuseRF = (reason) => {
setIsLoadingRefuse(true);
const data = {
status: 6, // STATUS_ARCHIVED
notes: reason,
};
const formData = new FormData();
formData.append('data', JSON.stringify(data));
editRegisterForm(studentId, formData, csrfToken)
.then((response) => {
logger.debug('RF refusé et archivé:', response);
showNotification('Le dossier a été refusé et archivé.', 'success', 'Succès');
router.push(FE_ADMIN_SUBSCRIPTIONS_URL);
setIsLoadingRefuse(false);
})
.catch((error) => {
showNotification('Erreur lors du refus du dossier.', 'error', 'Erreur');
setIsLoadingRefuse(false);
logger.error('Erreur lors du refus du RF:', error);
});
};
if (isLoading) {
return <Loader />;
}
@ -97,6 +121,8 @@ export default function Page() {
student_file={student_file}
onAccept={handleAcceptRF}
classes={classes}
onRefuse={handleRefuseRF}
isLoadingRefuse={isLoadingRefuse}
/>
);
}

View File

@ -0,0 +1,69 @@
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

@ -11,6 +11,7 @@ import logger from '@/utils/logger';
import { School, CheckCircle, Hourglass, FileText } from 'lucide-react';
import SectionHeader from '@/components/SectionHeader';
import Button from '@/components/Form/Button';
import RefuseSubscription from './RefuseSubscription';
export default function ValidateSubscription({
studentId,
@ -20,6 +21,8 @@ export default function ValidateSubscription({
student_file,
onAccept,
classes,
onRefuse, // callback pour refus
isLoadingRefuse = false, // état de chargement refus
}) {
const [schoolFileTemplates, setSchoolFileTemplates] = useState([]);
const [parentFileTemplates, setParentFileTemplates] = useState([]);
@ -162,8 +165,8 @@ export default function ValidateSubscription({
)}
</div>
{/* Colonne droite : Liste des documents, Option de fusion et Affectation */}
<div className="w-1/4 flex flex-col gap-4">
{/* Colonne droite : Liste des documents, Option de fusion, Affectation, Refus */}
<div className="w-1/4 flex flex-col flex-1 gap-4">
{/* 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">
@ -194,37 +197,35 @@ export default function ValidateSubscription({
</div>
{/* Option de fusion */}
<div className="bg-gray-50 p-4 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Option de fusion
</h3>
<div className="flex items-center justify-between">
<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"
/>
</div>
</div>
{/* Section Affectation */}
<div className="bg-white p-4 rounded-lg shadow-sm border border-gray-200">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Affectation à une classe
</h3>
<div className="flex flex-col gap-4">
<SelectChoice
name="associated_class"
label="Classe"
placeHolder="Sélectionner une classe"
selected={formData.associated_class || ''} // La valeur actuelle de la classe associée
callback={(e) => onChange('associated_class', e.target.value)} // Met à jour formData
choices={classes.map((classe) => ({
value: classe.id,
label: classe.atmosphere_name,
}))} // Liste des classes disponibles
required
/>
{/* Boutons Valider/Refuser en bas, centrés */}
<div className="mt-auto flex justify-center gap-4 py-4">
<Button
text="Valider le dossier d'inscription"
onClick={(e) => {
@ -232,14 +233,14 @@ export default function ValidateSubscription({
handleAssignClass();
}}
primary
className={`px-4 py-2 rounded-md shadow-sm focus:outline-none ${
className={`min-w-[220px] h-10 rounded-md shadow-sm focus:outline-none ${
!isPageValid
? 'bg-gray-300 text-gray-700 cursor-not-allowed'
: 'bg-emerald-500 text-white hover:bg-emerald-600'
}`}
disabled={!isPageValid}
/>
</div>
<RefuseSubscription onRefuse={onRefuse} isLoading={isLoadingRefuse} />
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
import React from 'react';
/**
* Textarea composant réutilisable
* @param {string} value - Valeur du textarea
* @param {function} onChange - Fonction appelée lors d'un changement
* @param {string} placeholder - Texte d'exemple
* @param {number} rows - Nombre de lignes
* @param {string} className - Classes CSS additionnelles
* @param {object} props - Props additionnels
*/
const Textarea = ({ value, onChange, placeholder = '', rows = 3, className = '', ...props }) => (
<textarea
value={value}
onChange={onChange}
placeholder={placeholder}
rows={rows}
className={`border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-y ${className}`}
{...props}
/>
);
export default Textarea;