Ajoutez des fichiers projet.
This commit is contained in:
579
project/src/components/NewLeaveRequestModal.jsx
Normal file
579
project/src/components/NewLeaveRequestModal.jsx
Normal file
@@ -0,0 +1,579 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Calendar, Clock, AlertCircle, RotateCcw } from 'lucide-react';
|
||||
|
||||
const NewLeaveRequestModal = ({
|
||||
onClose,
|
||||
availableLeaveCounters,
|
||||
userId,
|
||||
onRequestSubmitted,
|
||||
preselectedStartDate = null,
|
||||
preselectedEndDate = null,
|
||||
preselectedType = null
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
types: preselectedType ? [preselectedType] : [],
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
reason: '',
|
||||
medicalDocuments: []
|
||||
});
|
||||
|
||||
const [typeDistribution, setTypeDistribution] = useState({});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [calculatedDays, setCalculatedDays] = useState(0);
|
||||
const [isPreselected, setIsPreselected] = useState(false);
|
||||
|
||||
// Vérifier si des valeurs sont pré-sélectionnées
|
||||
useEffect(() => {
|
||||
if (preselectedStartDate || preselectedEndDate || preselectedType) {
|
||||
setIsPreselected(true);
|
||||
|
||||
// Pré-remplir automatiquement les dates
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
types: preselectedType ? [preselectedType] : prev.types,
|
||||
startDate: preselectedStartDate ? preselectedStartDate.toISOString().split('T')[0] : prev.startDate,
|
||||
endDate: preselectedEndDate ? preselectedEndDate.toISOString().split('T')[0] :
|
||||
preselectedStartDate ? preselectedStartDate.toISOString().split('T')[0] : prev.endDate
|
||||
}));
|
||||
}
|
||||
}, [preselectedStartDate, preselectedEndDate, preselectedType]);
|
||||
|
||||
// Calculer le nombre de jours ouvrés
|
||||
const calculateWorkingDays = (start, end) => {
|
||||
if (!start || !end) return 0;
|
||||
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
let workingDays = 0;
|
||||
|
||||
const current = new Date(startDate);
|
||||
while (current <= endDate) {
|
||||
const dayOfWeek = current.getDay();
|
||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) { // Pas dimanche (0) ni samedi (6)
|
||||
workingDays++;
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return workingDays;
|
||||
};
|
||||
|
||||
// Recalculer les jours quand les dates changent
|
||||
useEffect(() => {
|
||||
const days = calculateWorkingDays(formData.startDate, formData.endDate);
|
||||
setCalculatedDays(days);
|
||||
}, [formData.startDate, formData.endDate]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleFileUpload = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
const validFiles = [];
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
for (const file of files) {
|
||||
// Vérifier le type de fichier
|
||||
const validTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
setError(`Le fichier "${file.name}" n'est pas un format valide. Formats acceptés : PDF, JPG, PNG`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Vérifier la taille
|
||||
if (file.size > maxSize) {
|
||||
setError(`Le fichier "${file.name}" est trop volumineux. Taille maximum : 5MB`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
medicalDocuments: [...prev.medicalDocuments, ...validFiles]
|
||||
}));
|
||||
|
||||
// Reset input pour permettre de re-sélectionner le même fichier
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeDocument = (index) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
medicalDocuments: prev.medicalDocuments.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const handleTypeToggle = (type) => {
|
||||
setFormData(prev => {
|
||||
const newTypes = prev.types.includes(type)
|
||||
? prev.types.filter(t => t !== type)
|
||||
: [...prev.types, type];
|
||||
return { ...prev, types: newTypes };
|
||||
});
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleDistributionChange = (type, days) => {
|
||||
setTypeDistribution(prev => ({
|
||||
...prev,
|
||||
[type]: Math.max(0, Math.min(days, calculatedDays))
|
||||
}));
|
||||
};
|
||||
|
||||
const getTotalDistributedDays = () => {
|
||||
return Object.values(typeDistribution).reduce((sum, days) => sum + (days || 0), 0);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
types: [],
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
reason: '',
|
||||
medicalDocuments: []
|
||||
});
|
||||
setTypeDistribution({});
|
||||
setIsPreselected(false);
|
||||
setError('');
|
||||
setCalculatedDays(0);
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (formData.types.length === 0) {
|
||||
setError('Veuillez sélectionner au moins un type de congé');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérification des documents pour congé maladie
|
||||
if (formData.types.includes('ABS') && formData.medicalDocuments.length === 0) {
|
||||
setError('Un justificatif médical est obligatoire pour les arrêts maladie');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.startDate || !formData.endDate) {
|
||||
setError('Veuillez sélectionner les dates de début et de fin');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (new Date(formData.startDate) > new Date(formData.endDate)) {
|
||||
setError('La date de fin doit être postérieure à la date de début');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (new Date(formData.startDate) < new Date()) {
|
||||
setError('Impossible de faire une demande pour une date passée');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Vérification de la distribution des jours
|
||||
if (formData.types.length > 1) {
|
||||
const totalDistributed = getTotalDistributedDays();
|
||||
if (totalDistributed !== calculatedDays) {
|
||||
setError(`Vous devez distribuer exactement ${calculatedDays} jours entre les types sélectionnés`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Vérification des soldes pour chaque type
|
||||
for (const type of formData.types) {
|
||||
const requiredDays = formData.types.length > 1 ? (typeDistribution[type] || 0) : calculatedDays;
|
||||
|
||||
if (type === 'CP' && requiredDays > availableLeaveCounters.availableCP) {
|
||||
setError(`Solde CP insuffisant. Vous avez ${availableLeaveCounters.availableCP} jours disponibles`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === 'RTT' && requiredDays > availableLeaveCounters.availableRTT) {
|
||||
setError(`Solde RTT insuffisant. Vous avez ${availableLeaveCounters.availableRTT} jours disponibles`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Créer une demande pour chaque type de congé
|
||||
const requests = formData.types.map(type => {
|
||||
const days = formData.types.length > 1 ? (typeDistribution[type] || 0) : calculatedDays;
|
||||
return {
|
||||
EmployeeId: userId,
|
||||
TypeConge: type,
|
||||
DateDebut: formData.startDate,
|
||||
DateFin: formData.endDate,
|
||||
Commentaire: formData.reason + (formData.types.length > 1 ? ` (${days} jours ${getTypeLabel(type)})` : ''),
|
||||
NumDays: days
|
||||
};
|
||||
});
|
||||
|
||||
// Soumettre toutes les demandes
|
||||
const responses = await Promise.all(
|
||||
requests.map(requestData =>
|
||||
fetch('http://localhost/GTA/project/public/submitLeaveRequest.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestData),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const results = await Promise.all(responses.map(r => r.json()));
|
||||
|
||||
const allSuccessful = results.every(result => result.success);
|
||||
|
||||
if (allSuccessful) {
|
||||
onRequestSubmitted?.();
|
||||
onClose();
|
||||
} else {
|
||||
const failedResults = results.filter(r => !r.success);
|
||||
setError(`Erreur lors de la soumission : ${failedResults.map(r => r.message).join(', ')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur:', error);
|
||||
setError('Erreur de connexion au serveur');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type) => {
|
||||
switch (type) {
|
||||
case 'CP': return 'Congés payés';
|
||||
case 'RTT': return 'RTT';
|
||||
case 'ABS': return 'Arrêt maladie';
|
||||
default: return type;
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailableDays = (type) => {
|
||||
switch (type) {
|
||||
case 'CP': return availableLeaveCounters.availableCP;
|
||||
case 'RTT': return availableLeaveCounters.availableRTT;
|
||||
case 'ABS': return '∞';
|
||||
default: return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
switch (type) {
|
||||
case 'CP': return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'RTT': return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'ABS': return 'bg-red-100 text-red-800 border-red-200';
|
||||
default: return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-2 lg:p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md max-h-[95vh] lg:max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 lg:p-6 border-b border-gray-100">
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-gray-900">
|
||||
{isPreselected ? 'Confirmer la demande' : 'Nouvelle demande de congé'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-4 lg:p-6 space-y-4 lg:space-y-6">
|
||||
{/* Pré-sélection info */}
|
||||
{isPreselected && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-800">Sélection depuis le calendrier</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-700">
|
||||
Les champs ont été pré-remplis selon votre sélection.
|
||||
Vous pouvez les modifier ou utiliser le bouton "Réinitialiser".
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type de congé */}
|
||||
<div>
|
||||
<label className="block text-sm lg:text-base font-medium text-gray-700 mb-2">
|
||||
Types de congé * {formData.types.length > 0 && `(${formData.types.length} sélectionné${formData.types.length > 1 ? 's' : ''})`}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ key: 'CP', label: 'Congés payés', available: availableLeaveCounters.availableCP },
|
||||
{ key: 'RTT', label: 'RTT', available: availableLeaveCounters.availableRTT },
|
||||
{ key: 'ABS', label: 'Arrêt maladie', available: '∞' }
|
||||
].map(type => (
|
||||
<div key={type.key} className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.types.includes(type.key)}
|
||||
onChange={() => handleTypeToggle(type.key)}
|
||||
disabled={isPreselected && preselectedType && preselectedType !== type.key}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className={`px-2 lg:px-3 py-1 rounded-full text-xs lg:text-sm font-medium border ${getTypeColor(type.key)}`}>
|
||||
{type.key === 'ABS' ? type.label : `${type.label} (${type.available} disponible${type.available !== 1 ? 's' : ''})`}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Distribution des jours si plusieurs types sélectionnés */}
|
||||
{formData.types.length > 1 && calculatedDays > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-yellow-800 mb-3">
|
||||
Répartition des {calculatedDays} jours entre les types :
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{formData.types.map(type => (
|
||||
<div key={type} className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getTypeColor(type)} flex-shrink-0`}>
|
||||
{getTypeLabel(type)}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={calculatedDays}
|
||||
value={typeDistribution[type] || 0}
|
||||
onChange={(e) => handleDistributionChange(type, parseInt(e.target.value) || 0)}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<span className="text-xs text-gray-600">jours</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="pt-2 border-t border-yellow-300">
|
||||
<p className="text-xs text-yellow-700">
|
||||
Total distribué : {getTotalDistributedDays()} / {calculatedDays} jours
|
||||
{getTotalDistributedDays() !== calculatedDays && (
|
||||
<span className="text-red-600 ml-2">
|
||||
⚠️ Répartition incomplète
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Date de début *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="startDate"
|
||||
value={formData.startDate}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Date de fin *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="endDate"
|
||||
value={formData.endDate}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Durée calculée */}
|
||||
{calculatedDays > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg p-3 lg:p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-gray-600" />
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Durée : {calculatedDays} jour{calculatedDays > 1 ? 's' : ''} ouvré{calculatedDays > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{formData.types.length === 1 && formData.types[0] !== 'ABS' && (
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Solde {getTypeLabel(formData.types[0])} disponible : {getAvailableDays(formData.types[0])} jour{getAvailableDays(formData.types[0]) !== 1 && getAvailableDays(formData.types[0]) !== '∞' ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
{formData.types.length > 1 && (
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Types sélectionnés : {formData.types.map(type => getTypeLabel(type)).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Motif */}
|
||||
<div>
|
||||
<label className="block text-sm lg:text-base font-medium text-gray-700 mb-2">
|
||||
Motif (optionnel)
|
||||
</label>
|
||||
<textarea
|
||||
name="reason"
|
||||
value={formData.reason}
|
||||
onChange={handleInputChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none text-sm"
|
||||
placeholder="Précisez le motif de votre demande..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Documents médicaux pour congé maladie */}
|
||||
{formData.types.includes('ABS') && (
|
||||
<div>
|
||||
<label className="block text-sm lg:text-base font-medium text-gray-700 mb-2">
|
||||
Justificatif médical * <span className="text-red-500">(obligatoire pour arrêt maladie)</span>
|
||||
</label>
|
||||
|
||||
{/* Zone de téléchargement */}
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 hover:border-gray-400 transition-colors">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
Glissez vos documents ici ou cliquez pour sélectionner
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Formats acceptés : PDF, JPG, PNG (max 5MB par fichier)
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
id="medical-documents"
|
||||
/>
|
||||
<label
|
||||
htmlFor="medical-documents"
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 cursor-pointer"
|
||||
>
|
||||
Sélectionner des fichiers
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste des fichiers sélectionnés */}
|
||||
{formData.medicalDocuments.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
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">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded flex items-center justify-center flex-shrink-0">
|
||||
{file.type === 'application/pdf' ? (
|
||||
<svg className="w-4 h-4 text-red-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{file.name}</p>
|
||||
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeDocument(index)}
|
||||
className="text-red-600 hover:text-red-800 p-1 flex-shrink-0"
|
||||
title="Supprimer ce fichier"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Erreur */}
|
||||
{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" />
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col lg:flex-row gap-3 pt-4">
|
||||
{isPreselected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Réinitialiser
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
|
||||
>
|
||||
{isSubmitting ? 'Envoi...' : 'Soumettre'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewLeaveRequestModal;
|
||||
22
project/src/components/ProtectedRoute.jsx
Normal file
22
project/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { user, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return user ? children : <Navigate to="/login" replace />;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
140
project/src/components/Sidebar.jsx
Normal file
140
project/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { LogOut, Calendar, Home, FileText, Building2, Menu, X, Users } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const Sidebar = ({ isOpen, onToggle }) => {
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const isActive = (path) => location.pathname === path;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`
|
||||
fixed inset-y-0 left-0 z-50 w-60 bg-white border-r border-gray-200 min-h-screen flex flex-col transform transition-transform duration-300 ease-in-out
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
{/* Mobile close button */}
|
||||
<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 Section */}
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<Building2 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">GTA</h2>
|
||||
<p className="text-sm text-gray-500">Gestion de congés</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="p-4 lg:p-6 border-b border-gray-100">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<img
|
||||
src={user?.avatar || "/assets/utilisateur.png"}
|
||||
alt="User"
|
||||
className="w-12 h-12 lg:w-16 lg:h-16 rounded-full object-cover mb-3"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900 text-sm lg:text-base">{user?.name || "Utilisateur"}</p>
|
||||
<p className="text-xs lg:text-sm text-gray-500">{user?.department || "Service"}</p>
|
||||
<span className="inline-block mt-2 px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
|
||||
Employé
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4">
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
to="/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-blue-700 border-r-2 border-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
<span className="font-medium">Dashboard</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/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-blue-700 border-r-2 border-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-5 h-5" />
|
||||
<span className="font-medium">Demandes</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/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-blue-700 border-r-2 border-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="w-5 h-5" />
|
||||
<span className="font-medium">Calendrier</span>
|
||||
</Link>
|
||||
|
||||
{(user?.role === 'Manager' || user?.role === 'Admin' || user?.role === 'Employe') && (
|
||||
<Link
|
||||
to="/manager"
|
||||
onClick={() => window.innerWidth < 1024 && onToggle()}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive("/manager")
|
||||
? "bg-blue-50 text-blue-700 border-r-2 border-blue-700"
|
||||
: "text-gray-700 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Users className="w-5 h-5" />
|
||||
<span className="font-medium">
|
||||
{user?.role === 'Employe' ? 'Mon équipe' : 'Équipe'}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Logout Button */}
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-3 px-4 py-3 w-full text-left text-gray-700 hover:bg-red-50 hover:text-red-600 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="font-medium">Déconnexion</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
Reference in New Issue
Block a user