V1_Fonctionnel_GTAV1_GTA
This commit is contained in:
@@ -178,31 +178,45 @@ const EditLeaveRequestModal = ({
|
||||
try {
|
||||
const formDataToSend = new FormData();
|
||||
|
||||
formDataToSend.append('requestId', request.id);
|
||||
formDataToSend.append('leaveType', parseInt(leaveType));
|
||||
// ⭐ Ajouter tous les champs texte AVANT les fichiers
|
||||
formDataToSend.append('requestId', request.id.toString());
|
||||
formDataToSend.append('leaveType', leaveType.toString());
|
||||
formDataToSend.append('startDate', startDate);
|
||||
formDataToSend.append('endDate', endDate);
|
||||
formDataToSend.append('reason', reason);
|
||||
formDataToSend.append('userId', userId);
|
||||
formDataToSend.append('reason', reason || '');
|
||||
formDataToSend.append('userId', userId.toString());
|
||||
formDataToSend.append('userEmail', userEmail);
|
||||
formDataToSend.append('userName', userName);
|
||||
formDataToSend.append('accessToken', accessToken);
|
||||
formDataToSend.append('accessToken', accessToken || '');
|
||||
|
||||
// ⭐ Calcul des jours selon le type
|
||||
const selectedType = leaveTypes.find(t => t.id === parseInt(leaveType));
|
||||
const daysToSend = selectedType?.key === 'Récup' ? saturdayCount : businessDays;
|
||||
formDataToSend.append('businessDays', daysToSend);
|
||||
formDataToSend.append('businessDays', daysToSend.toString());
|
||||
|
||||
// ⭐ Documents médicaux
|
||||
medicalDocuments.forEach((file) => {
|
||||
formDataToSend.append('medicalDocuments', file);
|
||||
});
|
||||
// ⭐ Documents médicaux EN DERNIER
|
||||
if (medicalDocuments.length > 0) {
|
||||
medicalDocuments.forEach((file) => {
|
||||
formDataToSend.append('medicalDocuments', file);
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('http://localhost:3000/updateRequest', {
|
||||
// ⭐ DEBUG : Vérifier le contenu
|
||||
console.log('📤 FormData à envoyer:');
|
||||
for (let pair of formDataToSend.entries()) {
|
||||
console.log(pair[0], ':', pair[1]);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/updateRequest', {
|
||||
method: 'POST',
|
||||
// ⭐ NE PAS mettre de Content-Type, le navigateur le fera automatiquement avec boundary
|
||||
body: formDataToSend
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -222,7 +236,7 @@ const EditLeaveRequestModal = ({
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
console.error('❌ Erreur:', error);
|
||||
setSubmitMessage({
|
||||
type: 'error',
|
||||
text: '❌ Une erreur est survenue. Veuillez réessayer.'
|
||||
|
||||
728
project/src/components/GlobalTutorial.jsx
Normal file
728
project/src/components/GlobalTutorial.jsx
Normal file
@@ -0,0 +1,728 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Joyride, { STATUS } from 'react-joyride';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const GlobalTutorial = ({ userId, userRole }) => {
|
||||
const [runTour, setRunTour] = useState(false);
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
const [availableSteps, setAvailableSteps] = useState([]);
|
||||
const location = useLocation();
|
||||
|
||||
const isEmployee = userRole === "Collaborateur" || userRole === "Apprenti";
|
||||
const canViewAllFilters = ['president', 'rh', 'admin', 'directeur de campus', 'directrice de campus'].includes(userRole?.toLowerCase());
|
||||
|
||||
// 🎯 NOUVELLE FONCTION : Vérifier si un élément existe dans le DOM
|
||||
const elementExists = (selector) => {
|
||||
return document.querySelector(selector) !== null;
|
||||
};
|
||||
|
||||
// 🎯 NOUVELLE FONCTION : Filtrer les étapes selon les éléments disponibles
|
||||
const filterAvailableSteps = (steps) => {
|
||||
return steps.filter(step => {
|
||||
// Les étapes centrées (body) sont toujours affichées
|
||||
if (step.target === 'body') return true;
|
||||
|
||||
// Pour les autres, vérifier si l'élément existe
|
||||
const element = document.querySelector(step.target);
|
||||
if (!element) {
|
||||
console.log(`⚠️ Élément non trouvé, étape ignorée: ${step.target}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérifier si l'élément est visible
|
||||
const isVisible = element.offsetParent !== null;
|
||||
if (!isVisible) {
|
||||
console.log(`⚠️ Élément caché, étape ignorée: ${step.target}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// 🎯 Déclencher le tutoriel avec vérification
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
let tutorialKey = '';
|
||||
|
||||
if (location.pathname === '/dashboard') {
|
||||
tutorialKey = 'dashboard';
|
||||
} else if (location.pathname === '/manager') {
|
||||
tutorialKey = 'manager';
|
||||
} else if (location.pathname === '/calendar') {
|
||||
tutorialKey = 'calendar';
|
||||
}
|
||||
|
||||
if (tutorialKey) {
|
||||
const hasSeenTutorial = localStorage.getItem(`${tutorialKey}-tutorial-completed-${userId}`);
|
||||
|
||||
if (!hasSeenTutorial) {
|
||||
// ⭐ NOUVEAU : Attendre que le DOM soit chargé
|
||||
setTimeout(() => {
|
||||
const allSteps = getTourSteps();
|
||||
const available = filterAvailableSteps(allSteps);
|
||||
|
||||
console.log(`📊 Étapes totales: ${allSteps.length}, disponibles: ${available.length}`);
|
||||
|
||||
if (available.length > 2) { // Au moins 3 étapes (intro + 1 élément + conclusion)
|
||||
setAvailableSteps(available);
|
||||
setRunTour(true);
|
||||
} else {
|
||||
console.log('⚠️ Pas assez d\'éléments pour le tutoriel, annulation');
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [userId, location.pathname]);
|
||||
|
||||
// 🎯 Obtenir les étapes selon la page actuelle
|
||||
const getTourSteps = () => {
|
||||
// ==================== DASHBOARD ====================
|
||||
if (location.pathname === '/dashboard') {
|
||||
return [
|
||||
{
|
||||
target: 'body',
|
||||
content: (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-2">👋 Bienvenue sur votre application GTA !</h2>
|
||||
<p>Découvrez toutes les fonctionnalités en quelques étapes. Ce tutoriel ne s'affichera qu'une seule fois.</p>
|
||||
</div>
|
||||
),
|
||||
placement: 'center',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="dashboard"]',
|
||||
content: '🏠 Accédez à votre tableau de bord pour voir vos soldes de congés.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="demandes"]',
|
||||
content: '📋 Consultez et gérez toutes vos demandes de congés ici.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="calendrier"]',
|
||||
content: '📅 Visualisez vos congés et ceux de votre équipe dans le calendrier.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="mon-equipe"]',
|
||||
content: '👥 Consultez votre équipe et leurs absences.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="nouvelle-demande"]',
|
||||
content: '➕ Cliquez ici pour créer une nouvelle demande de congé, RTT ou récupération.',
|
||||
placement: 'left',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="notifications"]',
|
||||
content: '🔔 Consultez ici vos notifications (validations, refus, modifications de vos demandes).',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="refresh"]',
|
||||
content: '🔄 Rafraîchissez manuellement vos données. Mais pas d\'inquiétude : elles se mettent à jour automatiquement en temps réel !',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="demandes-recentes"]',
|
||||
content: '📄 Consultez rapidement vos 5 dernières demandes et leur statut. Cliquez sur "Voir toutes les demandes" pour accéder à la page complète.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="conges-service"]',
|
||||
content: '👥 Visualisez les congés de votre service pour le mois en cours. Pratique pour planifier vos absences !',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: 'body',
|
||||
content: (
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-2">📊 Vos compteurs de congés</h2>
|
||||
<p>Découvrez maintenant vos différents soldes de congés disponibles.</p>
|
||||
</div>
|
||||
),
|
||||
placement: 'center',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="cp-n-1"]',
|
||||
content: '📅 Vos congés payés de l\'année précédente. ⚠️ Attention : ils doivent être soldés avant le 31 décembre de l\'année en cours !',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="cp-n"]',
|
||||
content: '📈 Vos congés payés de l\'année en cours, en cours d\'acquisition. Ils se cumulent au fil des mois travaillés.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="rtt"]',
|
||||
content: '⏰ Vos RTT disponibles pour l\'année en cours. Ils sont acquis progressivement et à consommer avant le 31/12.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="recup"]',
|
||||
content: '🔄 Vos jours de récupération accumulés suite à des heures supplémentaires.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: 'body',
|
||||
content: (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-2">🎉 Vous êtes prêt !</h2>
|
||||
<p className="mb-3">Vous pouvez maintenant utiliser l'application en toute autonomie.</p>
|
||||
<div className="bg-cyan-50 border border-cyan-200 rounded-lg p-3 mt-3">
|
||||
<p className="text-sm text-cyan-900">
|
||||
💡 <strong>Besoin d'aide ?</strong> Cliquez sur le bouton <strong>"Aide"</strong> 🆘 en bas à droite pour relancer ce tutoriel à tout moment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
placement: 'center',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ==================== MANAGER ====================
|
||||
if (location.pathname === '/manager') {
|
||||
const baseSteps = [
|
||||
{
|
||||
target: 'body',
|
||||
content: (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-2">👥 Bienvenue dans la gestion d'équipe !</h2>
|
||||
<p>Découvrez comment gérer {isEmployee ? 'votre équipe' : 'les demandes de congés de votre équipe'}.</p>
|
||||
</div>
|
||||
),
|
||||
placement: 'center',
|
||||
disableBeacon: true,
|
||||
}
|
||||
];
|
||||
|
||||
if (!isEmployee) {
|
||||
// Pour les managers/validateurs
|
||||
return [
|
||||
...baseSteps,
|
||||
{
|
||||
target: '[data-tour="demandes-attente"]',
|
||||
content: '⏳ Consultez ici toutes les demandes en attente de validation. Vous pouvez les approuver ou les refuser directement.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="approuver-btn"]',
|
||||
content: '✅ Cliquez sur "Approuver" pour valider une demande. Vous pourrez ajouter un commentaire optionnel.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="refuser-btn"]',
|
||||
content: '❌ Cliquez sur "Refuser" pour rejeter une demande. Un commentaire expliquant le motif sera obligatoire.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="mon-equipe"]',
|
||||
content: '👥 Consultez la liste complète de votre équipe. Cliquez sur un membre pour voir le détail de ses demandes.',
|
||||
placement: 'left',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="historique-demandes"]',
|
||||
content: '📋 L\'historique complet de toutes les demandes de votre équipe avec leur statut (validée, refusée, en attente).',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="document-joint"]',
|
||||
content: '📎 Si un document est joint à une demande (certificat médical par exemple), vous pouvez le consulter ici.',
|
||||
placement: 'left',
|
||||
},
|
||||
{
|
||||
target: 'body',
|
||||
content: (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-2">🎉 Vous êtes prêt à gérer votre équipe !</h2>
|
||||
<p className="mb-3">Vous savez maintenant valider les demandes et suivre les absences de vos collaborateurs.</p>
|
||||
<div className="bg-cyan-50 border border-cyan-200 rounded-lg p-3 mt-3">
|
||||
<p className="text-sm text-cyan-900">
|
||||
💡 <strong>Astuce :</strong> Les données se mettent à jour automatiquement en temps réel. Vous recevrez des notifications pour chaque nouvelle demande.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
placement: 'center',
|
||||
}
|
||||
];
|
||||
} else {
|
||||
// Pour les collaborateurs/apprentis
|
||||
return [
|
||||
...baseSteps,
|
||||
{
|
||||
target: '[data-tour="mon-equipe"]',
|
||||
content: '👥 Consultez ici la liste de votre équipe. Vous pouvez voir les membres de votre service.',
|
||||
placement: 'left',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="membre-equipe"]',
|
||||
content: '👤 Cliquez sur un membre pour voir le détail de ses informations et absences.',
|
||||
placement: 'left',
|
||||
},
|
||||
{
|
||||
target: 'body',
|
||||
content: (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-2">✅ C'est tout pour cette section !</h2>
|
||||
<p className="mb-3">Vous pouvez maintenant consulter votre équipe facilement.</p>
|
||||
<div className="bg-cyan-50 border border-cyan-200 rounded-lg p-3 mt-3">
|
||||
<p className="text-sm text-cyan-900">
|
||||
💡 <strong>Besoin d'aide ?</strong> N'hésitez pas à contacter votre manager pour toute question.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
placement: 'center',
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== CALENDAR ====================
|
||||
if (location.pathname === '/calendar') {
|
||||
const baseSteps = [
|
||||
{
|
||||
target: 'body',
|
||||
content: (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-2">📅 Bienvenue dans le calendrier !</h2>
|
||||
<p>Découvrez comment visualiser et gérer les congés {canViewAllFilters ? 'de toute l\'entreprise' : 'de votre équipe'}.</p>
|
||||
</div>
|
||||
),
|
||||
placement: 'center',
|
||||
disableBeacon: true,
|
||||
},
|
||||
{
|
||||
target: '[data-tour="pto-counter"]',
|
||||
content: '📊 Votre solde PTO (Paid Time Off) total : somme de vos CP N-1, CP N et RTT disponibles.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="navigation-mois"]',
|
||||
content: '◀️▶️ Naviguez entre les mois pour consulter les congés passés et à venir.',
|
||||
placement: 'bottom',
|
||||
}
|
||||
];
|
||||
|
||||
// Étapes pour les filtres selon le rôle
|
||||
if (canViewAllFilters) {
|
||||
baseSteps.push(
|
||||
{
|
||||
target: '[data-tour="filtres-btn"]',
|
||||
content: '🔍 Accédez aux filtres pour affiner votre vue : société, campus, service, collaborateurs...',
|
||||
placement: 'left',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="filtre-societe"]',
|
||||
content: '🏢 Filtrez par société pour voir uniquement les congés d\'une entité spécifique.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="filtre-campus"]',
|
||||
content: '🏫 Filtrez par campus pour visualiser les absences par site géographique.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="filtre-service"]',
|
||||
content: '👔 Filtrez par service pour voir les congés d\'un département spécifique.',
|
||||
placement: 'bottom',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Étapes communes pour tous
|
||||
baseSteps.push(
|
||||
{
|
||||
target: '[data-tour="selection-collaborateurs"]',
|
||||
content: '👥 Sélectionnez les collaborateurs que vous souhaitez afficher dans le calendrier. Pratique pour se concentrer sur certaines personnes !',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="refresh-btn"]',
|
||||
content: '🔄 Rafraîchissez manuellement les données. Mais rassurez-vous : elles se mettent à jour automatiquement en temps réel via SSE !',
|
||||
placement: 'left',
|
||||
},
|
||||
{
|
||||
target: 'body',
|
||||
content: (
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mb-2">📅 Sélectionner des dates</h2>
|
||||
<p>Vous pouvez sélectionner des dates directement dans le calendrier pour créer une demande de congé rapidement.</p>
|
||||
</div>
|
||||
),
|
||||
placement: 'center',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="calendar-grid"]',
|
||||
content: '🖱️ Cliquez sur une date de début, puis sur une date de fin pour sélectionner une période. Un menu contextuel apparaîtra pour choisir le type de congé.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="legende"]',
|
||||
content: '🎨 La légende vous aide à identifier les différents types de congés : validés (vert), en attente (orange), formation (bleu), etc.',
|
||||
placement: 'top',
|
||||
},
|
||||
{
|
||||
target: 'body',
|
||||
content: (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-2">🎉 Vous maîtrisez le calendrier !</h2>
|
||||
<p className="mb-3">Vous savez maintenant visualiser les congés, filtrer par équipe et créer rapidement des demandes.</p>
|
||||
<div className="bg-cyan-50 border border-cyan-200 rounded-lg p-3 mt-3">
|
||||
<p className="text-sm text-cyan-900">
|
||||
💡 <strong>Astuce :</strong> Survolez une case de congé pour voir tous les détails (employé, type, période, statut). Sur mobile, appuyez sur la case !
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
placement: 'center',
|
||||
}
|
||||
);
|
||||
|
||||
return baseSteps;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
// 🎯 Obtenir la clé localStorage selon la page
|
||||
const getTutorialKey = () => {
|
||||
if (location.pathname === '/dashboard') return 'dashboard';
|
||||
if (location.pathname === '/manager') return 'manager';
|
||||
if (location.pathname === '/calendar') return 'calendar';
|
||||
return '';
|
||||
};
|
||||
|
||||
// 🎯 Gérer la fin du tutoriel
|
||||
const handleJoyrideCallback = (data) => {
|
||||
const { status } = data;
|
||||
const finishedStatuses = [STATUS.FINISHED, STATUS.SKIPPED];
|
||||
|
||||
if (finishedStatuses.includes(status)) {
|
||||
setRunTour(false);
|
||||
setDontShowAgain(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Si on n'a pas d'étapes disponibles, ne rien afficher
|
||||
if (availableSteps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Joyride
|
||||
steps={availableSteps}
|
||||
run={runTour}
|
||||
continuous
|
||||
showProgress={true}
|
||||
showSkipButton={false}
|
||||
scrollToFirstStep
|
||||
scrollOffset={100}
|
||||
callback={handleJoyrideCallback}
|
||||
styles={{
|
||||
options: {
|
||||
primaryColor: '#0891b2',
|
||||
zIndex: 10000,
|
||||
},
|
||||
}}
|
||||
floaterProps={{
|
||||
disableAnimation: true,
|
||||
}}
|
||||
locale={{
|
||||
back: 'Retour',
|
||||
close: 'Fermer',
|
||||
last: 'Terminer',
|
||||
next: 'Suivant',
|
||||
skip: 'Passer'
|
||||
}}
|
||||
tooltipComponent={({
|
||||
continuous,
|
||||
index,
|
||||
step,
|
||||
backProps,
|
||||
primaryProps,
|
||||
skipProps,
|
||||
closeProps,
|
||||
tooltipProps,
|
||||
size,
|
||||
isLastStep
|
||||
}) => {
|
||||
const [showConfirmModal, setShowConfirmModal] = React.useState(false);
|
||||
const tutorialKey = getTutorialKey();
|
||||
|
||||
const handleFinish = () => {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem(`${tutorialKey}-tutorial-completed-${userId}`, 'true');
|
||||
}
|
||||
setRunTour(false);
|
||||
setDontShowAgain(false);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
if (dontShowAgain) {
|
||||
setShowConfirmModal(true);
|
||||
} else {
|
||||
setRunTour(false);
|
||||
setDontShowAgain(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSkip = () => {
|
||||
localStorage.setItem(`${tutorialKey}-tutorial-completed-${userId}`, 'true');
|
||||
setShowConfirmModal(false);
|
||||
setRunTour(false);
|
||||
setDontShowAgain(false);
|
||||
};
|
||||
|
||||
const cancelSkip = () => {
|
||||
setShowConfirmModal(false);
|
||||
setDontShowAgain(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Modal de confirmation */}
|
||||
{showConfirmModal && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10001
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
cancelSkip();
|
||||
}
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '16px',
|
||||
padding: '24px',
|
||||
maxWidth: '400px',
|
||||
width: '90%',
|
||||
boxShadow: '0 20px 50px rgba(0,0,0,0.3)'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
⚠️
|
||||
</div>
|
||||
<h3 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '12px',
|
||||
color: '#111827',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
Ne plus afficher le tutoriel ?
|
||||
</h3>
|
||||
<p style={{
|
||||
fontSize: '14px',
|
||||
color: '#6b7280',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
lineHeight: '1.5'
|
||||
}}>
|
||||
Êtes-vous sûr de vouloir désactiver définitivement ce tutoriel ?
|
||||
{tutorialKey === 'dashboard' && ' Vous pourrez le réactiver plus tard en cliquant sur le bouton "Aide".'}
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<button
|
||||
onClick={cancelSkip}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #d1d5db',
|
||||
backgroundColor: 'white',
|
||||
color: '#374151',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmSkip}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
backgroundColor: '#ef4444',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Oui, ne plus afficher
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tooltip principal */}
|
||||
<div {...tooltipProps} style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 10px 25px rgba(0,0,0,0.15)',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
<div style={{ marginBottom: '15px', color: '#374151' }}>
|
||||
{step.content}
|
||||
</div>
|
||||
|
||||
{/* Case à cocher "Ne plus afficher" */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginTop: '12px',
|
||||
marginBottom: '12px',
|
||||
padding: '10px',
|
||||
backgroundColor: '#f9fafb',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`dont-show-again-${index}`}
|
||||
checked={dontShowAgain}
|
||||
onChange={(e) => setDontShowAgain(e.target.checked)}
|
||||
style={{
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
cursor: 'pointer',
|
||||
accentColor: '#0891b2'
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`dont-show-again-${index}`}
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#374151',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
fontWeight: '500'
|
||||
}}
|
||||
>
|
||||
Ne plus afficher ce tutoriel
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: '12px',
|
||||
borderTop: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<span style={{ fontSize: '13px', color: '#6b7280', fontWeight: '500' }}>
|
||||
Étape {index + 1} sur {size}
|
||||
</span>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{index > 0 && (
|
||||
<button
|
||||
{...backProps}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #d1d5db',
|
||||
backgroundColor: 'white',
|
||||
color: '#6b7280',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s'
|
||||
}}>
|
||||
Retour
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isLastStep && (
|
||||
<button
|
||||
{...primaryProps}
|
||||
style={{
|
||||
padding: '8px 18px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
backgroundColor: '#0891b2',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLastStep && (
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
style={{
|
||||
padding: '8px 18px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
backgroundColor: '#0891b2',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Terminer
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #d1d5db',
|
||||
backgroundColor: 'white',
|
||||
color: '#6b7280',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
Passer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default GlobalTutorial;
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -15,7 +15,7 @@ const MedicalDocuments = ({ demandeId }) => {
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`http://localhost:3000/medical-documents/${demandeId}`);
|
||||
const response = await fetch(`/api/medical-documents/${demandeId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
@@ -116,7 +116,7 @@ const MedicalDocuments = ({ demandeId }) => {
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`http://localhost:3000${doc.downloadUrl}`}
|
||||
href={`${doc.downloadUrl}`}
|
||||
download
|
||||
className="flex-shrink-0 p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="Télécharger"
|
||||
|
||||
@@ -28,11 +28,52 @@ const NewLeaveRequestModal = ({
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const safeCounters = {
|
||||
availableCP: availableLeaveCounters?.availableCP ?? 0,
|
||||
availableRTT: availableLeaveCounters?.availableRTT ?? 0
|
||||
// ⭐ État pour les données des compteurs
|
||||
const [countersData, setCountersData] = useState(null);
|
||||
const [isLoadingCounters, setIsLoadingCounters] = useState(true);
|
||||
|
||||
// ⭐ Charger les compteurs au montage
|
||||
useEffect(() => {
|
||||
const fetchCounters = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
setIsLoadingCounters(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/getDetailedLeaveCounters?user_id=${userId}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log('📊 Compteurs reçus:', data);
|
||||
setCountersData(data);
|
||||
} else {
|
||||
console.error('❌ Erreur compteurs:', data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur réseau compteurs:', error);
|
||||
} finally {
|
||||
setIsLoadingCounters(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCounters();
|
||||
}, [userId]);
|
||||
|
||||
// ⭐ Utiliser les données des compteurs
|
||||
const safeCounters = countersData ? {
|
||||
availableCP: parseFloat(countersData.data?.totalDisponible?.cp || 0),
|
||||
availableRTT: parseFloat(countersData.data?.totalDisponible?.rtt || 0),
|
||||
availableRecup: parseFloat(countersData.data?.totalDisponible?.recup || 0)
|
||||
} : {
|
||||
availableCP: 0,
|
||||
availableRTT: 0,
|
||||
availableRecup: 0
|
||||
};
|
||||
|
||||
console.log('📊 Compteurs disponibles:', safeCounters);
|
||||
console.log('📊 Données complètes:', countersData);
|
||||
|
||||
useEffect(() => {
|
||||
if (preselectedStartDate || preselectedEndDate) {
|
||||
setFormData(prev => ({
|
||||
@@ -44,7 +85,6 @@ const NewLeaveRequestModal = ({
|
||||
}
|
||||
}, [preselectedStartDate, preselectedEndDate, preselectedType]);
|
||||
|
||||
// 🔹 Calcul automatique - JOURS OUVRÉS UNIQUEMENT (Lun-Ven)
|
||||
useEffect(() => {
|
||||
if (formData.startDate && formData.endDate) {
|
||||
const start = new Date(formData.startDate);
|
||||
@@ -70,8 +110,7 @@ const NewLeaveRequestModal = ({
|
||||
debut: formData.startDate,
|
||||
fin: formData.endDate,
|
||||
joursOuvres: workingDays,
|
||||
samedis: saturdays,
|
||||
message: 'Les samedis ne sont comptés QUE si "Récup" est coché'
|
||||
samedis: saturdays
|
||||
});
|
||||
}
|
||||
}, [formData.startDate, formData.endDate]);
|
||||
@@ -105,20 +144,15 @@ const NewLeaveRequestModal = ({
|
||||
const handlePeriodeChange = (type, periode) => {
|
||||
setPeriodeSelection(prev => ({ ...prev, [type]: periode }));
|
||||
|
||||
// ⭐ CORRECTION : Ajuster automatiquement pour TOUTES les situations
|
||||
if (formData.types.length === 1) {
|
||||
// Si un seul type : ajuster selon la période
|
||||
if (periode === 'Matin' || periode === 'Après-midi') {
|
||||
setRepartition(prev => ({ ...prev, [type]: 0.5 }));
|
||||
} else {
|
||||
setRepartition(prev => ({ ...prev, [type]: totalDays }));
|
||||
}
|
||||
} else {
|
||||
// Si plusieurs types : garder la répartition manuelle
|
||||
// MAIS si c'est une période partielle sur une journée unique
|
||||
if (formData.startDate === formData.endDate && (periode === 'Matin' || periode === 'Après-midi')) {
|
||||
const currentRepartition = repartition[type] || 0;
|
||||
// Suggérer 0.5 si pas encore défini
|
||||
if (currentRepartition === 0 || currentRepartition === 1) {
|
||||
setRepartition(prev => ({ ...prev, [type]: 0.5 }));
|
||||
}
|
||||
@@ -126,7 +160,6 @@ const NewLeaveRequestModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleFileUpload = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
const validFiles = [];
|
||||
@@ -170,6 +203,15 @@ const NewLeaveRequestModal = ({
|
||||
const validateForm = () => {
|
||||
console.log('\n🔍 === VALIDATION FORMULAIRE ===');
|
||||
|
||||
// 🔥 AJOUTER CES LOGS
|
||||
console.log('📊 countersData:', countersData);
|
||||
console.log('📊 countersData.success:', countersData?.success);
|
||||
console.log('📊 countersData.data:', countersData?.data);
|
||||
console.log('📊 totalDisponible:', countersData?.data?.totalDisponible);
|
||||
console.log('📊 totalDisponible.cp:', countersData?.data?.totalDisponible?.cp);
|
||||
console.log('📊 safeCounters:', safeCounters);
|
||||
|
||||
// Vérifications de base
|
||||
if (formData.types.length === 0) {
|
||||
setError('Veuillez sélectionner au moins un type de congé');
|
||||
return false;
|
||||
@@ -200,58 +242,82 @@ const NewLeaveRequestModal = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasRecup = formData.types.includes('Récup');
|
||||
const hasABS = formData.types.includes('ABS');
|
||||
|
||||
// ⭐ NOUVEAU : Calculer le total attendu en tenant compte des demi-journées
|
||||
let expectedTotal = totalDays;
|
||||
|
||||
// Si un seul type avec demi-journée sur une journée unique
|
||||
if (formData.types.length === 1 && formData.startDate === formData.endDate) {
|
||||
const type = formData.types[0];
|
||||
const periode = periodeSelection[type];
|
||||
|
||||
if ((type === 'CP' || type === 'RTT') && (periode === 'Matin' || periode === 'Après-midi')) {
|
||||
expectedTotal = 0.5;
|
||||
console.log('📊 Demi-journée détectée, expectedTotal = 0.5');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📊 Analyse:', {
|
||||
joursOuvres: totalDays,
|
||||
expectedTotal: expectedTotal,
|
||||
samedis: saturdayCount,
|
||||
typesCoches: formData.types,
|
||||
hasRecup
|
||||
});
|
||||
|
||||
if (hasRecup && saturdayCount === 0) {
|
||||
setError('Une récupération nécessite au moins un samedi dans la période. Veuillez sélectionner une période incluant un samedi ou décocher "Récupération (samedi)".');
|
||||
if (hasABS && formData.types.length > 1) {
|
||||
setError('Un arrêt maladie ne peut pas être mélangé avec d\'autres types de congés');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (saturdayCount > 0 && !hasRecup) {
|
||||
console.log(`⚠️ ${saturdayCount} samedi(s) détecté(s) mais "Récup" non coché - Les samedis seront ignorés`);
|
||||
}
|
||||
|
||||
if (hasABS && formData.medicalDocuments.length === 0) {
|
||||
setError('Un justificatif médical est obligatoire pour un arrêt maladie');
|
||||
return false;
|
||||
}
|
||||
|
||||
// ⭐ VALIDATION RÉPARTITION AMÉLIORÉE
|
||||
if (formData.types.length > 1) {
|
||||
const sum = Object.values(repartition).reduce((a, b) => a + b, 0);
|
||||
// VALIDATION DES SOLDES AVEC ANTICIPATION
|
||||
// 🔥 CONDITION MODIFIÉE : Vérifier que les données sont bien chargées
|
||||
if (!countersData || !countersData.data || !countersData.data.totalDisponible) {
|
||||
console.error('❌ Données compteurs non disponibles pour validation !');
|
||||
setError('Erreur : Les compteurs ne sont pas chargés. Veuillez réessayer.');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('📊 Validation répartition:', {
|
||||
somme: sum,
|
||||
attendu: expectedTotal,
|
||||
joursOuvres: totalDays,
|
||||
samedisIgnores: !hasRecup ? saturdayCount : 0
|
||||
// Calculer les jours demandés par type
|
||||
const joursDemandesParType = {};
|
||||
|
||||
if (formData.types.length === 1) {
|
||||
const type = formData.types[0];
|
||||
const periode = periodeSelection[type] || 'Journée entière';
|
||||
|
||||
if (formData.startDate === formData.endDate && (periode === 'Matin' || periode === 'Après-midi')) {
|
||||
joursDemandesParType[type] = 0.5;
|
||||
} else {
|
||||
joursDemandesParType[type] = totalDays;
|
||||
}
|
||||
} else {
|
||||
formData.types.forEach(type => {
|
||||
joursDemandesParType[type] = repartition[type] || 0;
|
||||
});
|
||||
}
|
||||
|
||||
if (Math.abs(sum - expectedTotal) > 0.01) {
|
||||
setError(`La somme des jours répartis (${sum.toFixed(1)}j) doit être égale au total de jours ouvrés (${expectedTotal}j). ${saturdayCount > 0 && !hasRecup ? `Les ${saturdayCount} samedi(s) ne sont pas comptés.` : ''}`);
|
||||
console.log('📊 Jours demandés:', joursDemandesParType);
|
||||
console.log('📊 Soldes disponibles:', safeCounters);
|
||||
|
||||
// Vérifier CP
|
||||
if (joursDemandesParType['CP'] > 0) {
|
||||
const cpDemande = joursDemandesParType['CP'];
|
||||
const cpDisponible = safeCounters.availableCP;
|
||||
|
||||
console.log(`🔍 CP: ${cpDemande}j demandés vs ${cpDisponible}j disponibles`);
|
||||
|
||||
if (cpDemande > cpDisponible) {
|
||||
setError(`Solde CP insuffisant (${cpDisponible.toFixed(2)}j disponibles avec anticipation, ${cpDemande}j demandés)`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier RTT
|
||||
if (joursDemandesParType['RTT'] > 0) {
|
||||
const rttDemande = joursDemandesParType['RTT'];
|
||||
const rttDisponible = safeCounters.availableRTT;
|
||||
|
||||
console.log(`🔍 RTT: ${rttDemande}j demandés vs ${rttDisponible}j disponibles`);
|
||||
|
||||
if (rttDemande > rttDisponible) {
|
||||
setError(`Solde RTT insuffisant (${rttDisponible.toFixed(2)}j disponibles avec anticipation, ${rttDemande}j demandés)`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifier Récup
|
||||
if (joursDemandesParType['Récup'] > 0) {
|
||||
const recupDemande = joursDemandesParType['Récup'];
|
||||
const recupDisponible = safeCounters.availableRecup;
|
||||
|
||||
console.log(`🔍 Récup: ${recupDemande}j demandés vs ${recupDisponible}j disponibles`);
|
||||
|
||||
if (recupDemande > recupDisponible) {
|
||||
setError(`Solde Récup insuffisant (${recupDisponible.toFixed(2)}j disponibles, ${recupDemande}j demandés)`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -261,6 +327,7 @@ const NewLeaveRequestModal = ({
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
|
||||
@@ -281,15 +348,13 @@ const NewLeaveRequestModal = ({
|
||||
formDataToSend.append('Nom', userName);
|
||||
formDataToSend.append('Commentaire', formData.reason || '');
|
||||
|
||||
// ⭐ CORRECTION : Calculer le NombreJours total correctement
|
||||
let totalJoursToSend = totalDays;
|
||||
|
||||
// Si un seul type avec demi-journée
|
||||
if (formData.types.length === 1 && formData.startDate === formData.endDate) {
|
||||
const type = formData.types[0];
|
||||
const periode = periodeSelection[type];
|
||||
|
||||
if ((type === 'CP' || type === 'RTT') && (periode === 'Matin' || periode === 'Après-midi')) {
|
||||
if ((type === 'CP' || type === 'RTT' || type === 'Récup') && (periode === 'Matin' || periode === 'Après-midi')) {
|
||||
totalJoursToSend = 0.5;
|
||||
}
|
||||
}
|
||||
@@ -297,50 +362,31 @@ const NewLeaveRequestModal = ({
|
||||
formDataToSend.append('NombreJours', totalJoursToSend);
|
||||
|
||||
const repartitionArray = formData.types.map(type => {
|
||||
if (type === 'Récup' && formData.types.length === 1) {
|
||||
console.log(`📝 Récup seul: ${saturdayCount} samedi(s)`);
|
||||
return {
|
||||
TypeConge: type,
|
||||
NombreJours: saturdayCount,
|
||||
PeriodeJournee: 'Journée entière'
|
||||
};
|
||||
}
|
||||
|
||||
if (type === 'Récup' && formData.types.length > 1) {
|
||||
const joursRecup = repartition[type] || saturdayCount;
|
||||
console.log(`📝 Récup (répartition): ${joursRecup}j`);
|
||||
return {
|
||||
TypeConge: type,
|
||||
NombreJours: joursRecup,
|
||||
PeriodeJournee: 'Journée entière'
|
||||
};
|
||||
}
|
||||
|
||||
// ⭐ CORRECTION : Gérer demi-journées pour un seul type
|
||||
let nombreJours;
|
||||
let periodeJournee = 'Journée entière';
|
||||
|
||||
if (formData.types.length === 1) {
|
||||
// Un seul type : utiliser soit 0.5 (demi-journée) soit totalDays
|
||||
const periode = periodeSelection[type];
|
||||
if ((type === 'CP' || type === 'RTT') &&
|
||||
const periode = periodeSelection[type] || 'Journée entière';
|
||||
|
||||
if ((type === 'CP' || type === 'RTT' || type === 'Récup') &&
|
||||
formData.startDate === formData.endDate &&
|
||||
(periode === 'Matin' || periode === 'Après-midi')) {
|
||||
nombreJours = 0.5;
|
||||
periodeJournee = periode;
|
||||
} else {
|
||||
nombreJours = totalDays;
|
||||
}
|
||||
} else {
|
||||
// Plusieurs types : utiliser la répartition manuelle
|
||||
nombreJours = repartition[type] || 0;
|
||||
periodeJournee = periodeSelection[type] || 'Journée entière';
|
||||
}
|
||||
|
||||
console.log(`📝 ${type}: ${nombreJours}j (${periodeSelection[type] || 'Journée entière'})`);
|
||||
console.log(`📝 ${type}: ${nombreJours}j (${periodeJournee})`);
|
||||
|
||||
return {
|
||||
TypeConge: type,
|
||||
NombreJours: nombreJours,
|
||||
PeriodeJournee: ['CP', 'RTT'].includes(type)
|
||||
? (periodeSelection[type] || 'Journée entière')
|
||||
: 'Journée entière'
|
||||
PeriodeJournee: ['CP', 'RTT', 'Récup'].includes(type) ? periodeJournee : 'Journée entière'
|
||||
};
|
||||
});
|
||||
|
||||
@@ -353,7 +399,7 @@ const NewLeaveRequestModal = ({
|
||||
formDataToSend.append('medicalDocuments', file);
|
||||
});
|
||||
|
||||
const response = await fetch('http://localhost:3000/submitLeaveRequest', {
|
||||
const response = await fetch('/api/submitLeaveRequest', {
|
||||
method: 'POST',
|
||||
body: formDataToSend
|
||||
});
|
||||
@@ -376,7 +422,6 @@ const NewLeaveRequestModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleTypeToggle = (type) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@@ -386,20 +431,74 @@ const NewLeaveRequestModal = ({
|
||||
}));
|
||||
};
|
||||
|
||||
const isTypeDisabled = (typeKey) => {
|
||||
const hasABS = formData.types.includes('ABS');
|
||||
const hasOtherTypes = formData.types.some(t => t !== 'ABS');
|
||||
|
||||
if (hasABS && typeKey !== 'ABS') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasOtherTypes && typeKey === 'ABS') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getDisabledTooltip = (typeKey) => {
|
||||
if (formData.types.includes('ABS') && typeKey !== 'ABS') {
|
||||
return '⚠️ Un arrêt maladie ne peut pas être mélangé avec d\'autres types';
|
||||
}
|
||||
if (formData.types.some(t => t !== 'ABS') && typeKey === 'ABS') {
|
||||
return '⚠️ Un arrêt maladie ne peut pas être mélangé avec d\'autres types';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// ⭐ Inclure les détails des compteurs dans availableTypes
|
||||
// ⭐ Inclure les détails des compteurs dans availableTypes
|
||||
const availableTypes = userRole === 'Apprenti'
|
||||
? [
|
||||
{ key: 'CP', label: 'Congés payés', available: safeCounters.availableCP },
|
||||
{
|
||||
key: 'CP',
|
||||
label: 'Congé(s) payé(s)',
|
||||
// ✅ Afficher seulement le solde actuel (sans anticipé)
|
||||
available: countersData?.data?.cpN?.solde || 0,
|
||||
details: countersData?.data?.cpN
|
||||
},
|
||||
{ key: 'ABS', label: 'Arrêt maladie' },
|
||||
{ key: 'Formation', label: 'Formation' },
|
||||
{ key: 'Récup', label: 'Récupération (samedi)' },
|
||||
{
|
||||
key: 'Récup',
|
||||
label: 'Récupération(s)',
|
||||
available: countersData?.data?.recupN?.solde || 0
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ key: 'CP', label: 'Congés payés', available: safeCounters.availableCP },
|
||||
{ key: 'RTT', label: 'RTT', available: safeCounters.availableRTT },
|
||||
{ key: 'ABS', label: 'Arrêt maladie' },
|
||||
{ key: 'Récup', label: 'Récupération (samedi)' },
|
||||
{
|
||||
key: 'CP',
|
||||
label: 'Congé(s) payé(s)',
|
||||
// ✅ Afficher seulement le solde actuel (sans anticipé)
|
||||
available: countersData?.data?.cpN?.solde || 0,
|
||||
details: countersData?.data?.cpN
|
||||
},
|
||||
{
|
||||
key: 'RTT',
|
||||
label: 'RTT',
|
||||
// ✅ Afficher seulement le solde actuel (sans anticipé)
|
||||
available: countersData?.data?.rttN?.solde || 0,
|
||||
details: countersData?.data?.rttN
|
||||
},
|
||||
{
|
||||
key: 'Récup',
|
||||
label: 'Récupération',
|
||||
available: countersData?.data?.recupN?.solde || 0
|
||||
},
|
||||
{ key: 'ABS', label: 'Arrêt maladie' }
|
||||
];
|
||||
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
@@ -411,34 +510,75 @@ const NewLeaveRequestModal = ({
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{/* ⭐ BLOC SOLDES DÉTAILLÉS */}
|
||||
|
||||
|
||||
{/* Loading */}
|
||||
{isLoadingCounters && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
|
||||
<div className="animate-spin h-6 w-6 border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-2"></div>
|
||||
<p className="text-sm text-gray-600">Chargement des soldes...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 mb-3">
|
||||
Types de congé *
|
||||
Types d'absences *
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{availableTypes.map(type => (
|
||||
<label key={type.key} className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.types.includes(type.key)}
|
||||
onChange={() => handleTypeToggle(type.key)}
|
||||
className="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<span className={`px-2.5 py-1 rounded text-sm font-medium ${type.key === 'CP' ? 'bg-blue-100 text-blue-800' :
|
||||
{availableTypes.map(type => {
|
||||
const disabled = isTypeDisabled(type.key);
|
||||
const tooltip = getDisabledTooltip(type.key);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={type.key}
|
||||
className={`flex items-center gap-3 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
title={tooltip}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.types.includes(type.key)}
|
||||
onChange={() => handleTypeToggle(type.key)}
|
||||
disabled={disabled}
|
||||
className={`w-4 h-4 rounded border-gray-300 ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
/>
|
||||
<span className={`px-2.5 py-1 rounded text-sm font-medium ${type.key === 'CP' ? 'bg-blue-100 text-blue-800' :
|
||||
type.key === 'RTT' ? 'bg-green-100 text-green-800' :
|
||||
type.key === 'ABS' ? 'bg-red-100 text-red-800' :
|
||||
'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{type.label}
|
||||
</span>
|
||||
{type.available !== undefined && (
|
||||
<span className="text-sm text-gray-600">
|
||||
({type.available.toFixed(2)} disponibles)
|
||||
type.key === 'Récup' ? 'bg-orange-100 text-orange-800' :
|
||||
'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{type.label}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{type.available !== undefined && (
|
||||
<div className="flex flex-col text-xs">
|
||||
<span className={`font-semibold ${type.details?.solde < 0 || type.details?.anticipe?.depassement > 0
|
||||
? 'text-red-600'
|
||||
: 'text-gray-600'
|
||||
}`}>
|
||||
({type.available.toFixed(2)} disponibles)
|
||||
</span>
|
||||
{type.details?.anticipe?.depassement > 0 && (
|
||||
<span className="text-red-600 text-xs italic">
|
||||
⚠️ Dépassement anticipation
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{formData.types.includes('ABS') && (
|
||||
<div className="mt-3 flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-amber-700 text-xs">
|
||||
Un arrêt maladie ne peut pas être combiné avec d'autres types de congés.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -468,12 +608,7 @@ const NewLeaveRequestModal = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* ⭐ SECTION PÉRIODE POUR UN SEUL TYPE */}
|
||||
{/* ⭐ SECTION PÉRIODE POUR UN SEUL TYPE */}
|
||||
{formData.types.length === 1 && ['CP', 'RTT'].includes(formData.types[0]) && (
|
||||
{formData.types.length === 1 && ['CP', 'RTT', 'Récup'].includes(formData.types[0]) && (
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||
Période de la journée
|
||||
@@ -496,7 +631,6 @@ const NewLeaveRequestModal = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 🎯 AFFICHAGE DU NOMBRE DE JOURS */}
|
||||
<div className="mt-3 flex items-center justify-center">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-2 inline-flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-blue-900">Durée sélectionnée :</span>
|
||||
@@ -506,10 +640,8 @@ const NewLeaveRequestModal = ({
|
||||
const periode = periodeSelection[type] || 'Journée entière';
|
||||
|
||||
if (formData.startDate === formData.endDate) {
|
||||
// Journée unique
|
||||
return (periode === 'Matin' || periode === 'Après-midi') ? '0.5 jour' : '1 jour';
|
||||
} else {
|
||||
// Plusieurs jours
|
||||
return (periode === 'Matin' || periode === 'Après-midi')
|
||||
? `${(totalDays - 0.5).toFixed(1)} jours`
|
||||
: `${totalDays} jour${totalDays > 1 ? 's' : ''}`;
|
||||
@@ -521,20 +653,17 @@ const NewLeaveRequestModal = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* ⭐ SECTION RÉPARTITION POUR PLUSIEURS TYPES */}
|
||||
{/* ⭐ SECTION RÉPARTITION POUR PLUSIEURS TYPES */}
|
||||
{formData.types.length > 1 && totalDays > 0 && (
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">
|
||||
Répartition des {totalDays} jours ouvrés
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
La somme doit être égale à {totalDays} jour(s)
|
||||
Indiquez la répartition souhaitée (le système vérifiera automatiquement)
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{formData.types.map((type) => {
|
||||
const showPeriode = ['CP', 'RTT'].includes(type);
|
||||
const showPeriode = ['CP', 'RTT', 'Récup'].includes(type);
|
||||
const currentValue = repartition[type] || 0;
|
||||
|
||||
return (
|
||||
@@ -547,11 +676,10 @@ const NewLeaveRequestModal = ({
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
max={type === 'Récup' ? saturdayCount : totalDays}
|
||||
max={totalDays}
|
||||
value={repartition[type] || ''}
|
||||
onChange={(e) => handleRepartitionChange(type, e.target.value)}
|
||||
className="w-24 px-2 py-1 border rounded text-right text-sm"
|
||||
placeholder={type === 'Récup' ? `Max ${saturdayCount}` : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -593,7 +721,6 @@ const NewLeaveRequestModal = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 🎯 AFFICHAGE DU NOMBRE DE JOURS POUR CE TYPE */}
|
||||
<div className="mt-2 text-center">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
Durée :
|
||||
@@ -611,7 +738,6 @@ const NewLeaveRequestModal = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||
Motif (optionnel)
|
||||
@@ -660,7 +786,7 @@ const NewLeaveRequestModal = ({
|
||||
{formData.medicalDocuments.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm font-medium text-gray-900 mb-2">
|
||||
Fichiers sélectionnés ({formData.medicalDocuments.length}) :
|
||||
Fichiers sélectionnés ({formData.medicalDocuments.length})
|
||||
</p>
|
||||
{formData.medicalDocuments.map((file, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
@@ -698,7 +824,7 @@ const NewLeaveRequestModal = ({
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-red-700 text-sm">{error}</p>
|
||||
<p className="text-red-700 text-sm whitespace-pre-line">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -713,7 +839,7 @@ const NewLeaveRequestModal = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || isLoadingCounters}
|
||||
className="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Envoi...' : 'Soumettre'}
|
||||
@@ -725,4 +851,4 @@ const NewLeaveRequestModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default NewLeaveRequestModal;
|
||||
export default NewLeaveRequestModal;
|
||||
|
||||
@@ -27,27 +27,48 @@ const Sidebar = ({ isOpen, onToggle }) => {
|
||||
return 'bg-cyan-600 text-white';
|
||||
case 'Collaboratrice':
|
||||
return 'bg-cyan-600 text-white';
|
||||
case 'Apprenti':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
// Vérifier si l'utilisateur peut voir le compte-rendu d'activités
|
||||
const canViewCompteRendu = () => {
|
||||
const allowedRoles = [
|
||||
'Validateur',
|
||||
'Validatrice',
|
||||
'Directeur de campus',
|
||||
'Directrice de campus',
|
||||
'President',
|
||||
'Admin',
|
||||
'RH'
|
||||
];
|
||||
return allowedRoles.includes(user?.role);
|
||||
};
|
||||
// ✅ VERSION ULTRA-ROBUSTE pour isForfaitJour
|
||||
const isForfaitJour = (() => {
|
||||
if (!user?.TypeContrat && !user?.typeContrat) return false;
|
||||
|
||||
// Vérifier si l'utilisateur est en forfait jour
|
||||
const isForfaitJour = user?.TypeContrat === 'forfait_jour' || user?.typeContrat === 'forfaitjour';
|
||||
const typeContrat = (user?.TypeContrat || user?.typeContrat || '').toString().toLowerCase();
|
||||
|
||||
// Normaliser : retirer espaces, underscores, tirets
|
||||
const normalized = typeContrat.replace(/[\s_-]/g, '');
|
||||
|
||||
return normalized === 'forfaitjour';
|
||||
})();
|
||||
|
||||
// ✅ Vérification pour l'accès équipe
|
||||
const hasTeamAccess = [
|
||||
'Collaborateur',
|
||||
'Collaboratrice',
|
||||
'Apprenti',
|
||||
'Validateur',
|
||||
'Validatrice',
|
||||
'Manager',
|
||||
'RH',
|
||||
'Directeur de campus',
|
||||
'Directrice de campus',
|
||||
'President',
|
||||
'Admin'
|
||||
].includes(user?.role);
|
||||
|
||||
const isCollaboratorRole = ['Collaborateur', 'Collaboratrice', 'Apprenti'].includes(user?.role);
|
||||
const teamPath = isCollaboratorRole ? '/collaborateur' : '/manager';
|
||||
|
||||
// 🐛 DEBUG
|
||||
console.log('👤 User:', user);
|
||||
console.log('📋 Type Contrat RAW:', user?.TypeContrat);
|
||||
console.log('📋 normalized:', (user?.TypeContrat || '').toString().toLowerCase().replace(/[\s_-]/g, ''));
|
||||
console.log('✅ isForfaitJour:', isForfaitJour);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -64,14 +85,12 @@ const Sidebar = ({ isOpen, onToggle }) => {
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}
|
||||
>
|
||||
{/* Bouton fermer (mobile) */}
|
||||
<div className="lg:hidden flex justify-end p-4">
|
||||
<button onClick={onToggle} className="p-2 rounded-lg hover:bg-gray-100">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<img
|
||||
@@ -82,7 +101,6 @@ const Sidebar = ({ isOpen, onToggle }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Infos utilisateur */}
|
||||
<div className="p-4 lg:p-6 border-b border-gray-100">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<img
|
||||
@@ -110,26 +128,27 @@ const Sidebar = ({ isOpen, onToggle }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
data-tour="dashboard"
|
||||
onClick={() => window.innerWidth < 1024 && onToggle()}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive("/dashboard")
|
||||
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
<span className="font-medium">Dashboard</span>
|
||||
<span className="font-medium">Tableau de bord</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/demandes"
|
||||
data-tour="demandes"
|
||||
onClick={() => window.innerWidth < 1024 && onToggle()}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive("/demandes")
|
||||
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-5 h-5" />
|
||||
@@ -138,24 +157,26 @@ const Sidebar = ({ isOpen, onToggle }) => {
|
||||
|
||||
<Link
|
||||
to="/calendrier"
|
||||
data-tour="calendrier"
|
||||
onClick={() => window.innerWidth < 1024 && onToggle()}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive("/calendrier")
|
||||
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="w-5 h-5" />
|
||||
<span className="font-medium">Calendrier</span>
|
||||
</Link>
|
||||
|
||||
{/* Lien Compte-Rendu d'Activités - Visible pour validateurs et directeurs */}
|
||||
{(canViewCompteRendu() || isForfaitJour) && (
|
||||
{/* ✅ Compte-Rendu avec vérification robuste */}
|
||||
{isForfaitJour && (
|
||||
<Link
|
||||
to="/compte-rendu-activites"
|
||||
data-tour="compte-rendu"
|
||||
onClick={() => window.innerWidth < 1024 && onToggle()}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive("/compte-rendu-activites")
|
||||
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-5 h-5" />
|
||||
@@ -163,38 +184,22 @@ const Sidebar = ({ isOpen, onToggle }) => {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{(user?.role === "Collaborateur" ||
|
||||
user?.role === "Collaboratrice" ||
|
||||
user?.role === "Apprenti" ||
|
||||
user?.role === "Validateur" ||
|
||||
user?.role === "Validatrice" ||
|
||||
user?.role === "Manager" ||
|
||||
user?.role === "RH" ||
|
||||
user?.role === "Directeur de campus" ||
|
||||
user?.role === "Directrice de campus" ||
|
||||
user?.role === "President" ||
|
||||
user?.role === "Admin") && (() => {
|
||||
const targetPath = (user?.role === "Collaborateur" || user?.role === "Apprenti" || user?.role === "Collaboratrice")
|
||||
? "/collaborateur"
|
||||
: "/manager";
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={targetPath}
|
||||
onClick={() => window.innerWidth < 1024 && onToggle()}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive(targetPath)
|
||||
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Users className="w-5 h-5" />
|
||||
<span className="font-medium">Mon équipe</span>
|
||||
</Link>
|
||||
);
|
||||
})()}
|
||||
{hasTeamAccess && (
|
||||
<Link
|
||||
to={teamPath}
|
||||
data-tour="mon-equipe"
|
||||
onClick={() => window.innerWidth < 1024 && onToggle()}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive(teamPath)
|
||||
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Users className="w-5 h-5" />
|
||||
<span className="font-medium">Mon équipe</span>
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Bouton déconnexion */}
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={logout}
|
||||
|
||||
Reference in New Issue
Block a user