Files
GTA/project/src/components/EditLeaveRequestModal.jsx
2026-01-12 12:16:53 +01:00

788 lines
34 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { X, AlertCircle, Upload, FileText, Image as ImageIcon, Trash2 } from 'lucide-react';
const EditLeaveRequestModal = ({
isOpen,
onClose,
request,
onRequestUpdated,
availableLeaveCounters,
userId,
userEmail,
userName,
accessToken
}) => {
// ========================================
// ÉTATS
// ========================================
const [selectedTypes, setSelectedTypes] = useState([]);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [reason, setReason] = useState('');
const [businessDays, setBusinessDays] = useState(0);
const [saturdayCount, setSaturdayCount] = useState(0);
// Répartition manuelle (multi-types)
const [repartition, setRepartition] = useState({});
// Période par type (Matin/Après-midi/Journée entière)
const [periodeSelection, setPeriodeSelection] = useState({});
// Documents médicaux
const [medicalDocuments, setMedicalDocuments] = useState([]);
const [isDragging, setIsDragging] = useState(false);
// Compteurs
const [countersData, setCountersData] = useState(null);
const [isLoadingCounters, setIsLoadingCounters] = useState(true);
// UI
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitMessage, setSubmitMessage] = useState({ type: '', text: '' });
const [validationErrors, setValidationErrors] = useState([]);
const availableTypes = [
{ id: 'CP', label: 'Congé payé', color: '#3b82f6' },
{ id: 'RTT', label: 'RTT', color: '#8b5cf6' },
{ id: 'Récup', label: 'Récupération', color: '#10b981' },
{ id: 'ABS', label: 'Arrêt maladie', color: '#ef4444' },
{ id: 'Formation', label: 'Formation', color: '#f59e0b' }
];
// ========================================
// INITIALISATION
// ========================================
useEffect(() => {
if (isOpen && request) {
console.log('📝 Initialisation EditModal avec request:', request);
// Dates
setStartDate(request.startDate || '');
setEndDate(request.endDate || '');
setReason(request.reason || '');
// Types (mapping inverse)
const typeMapping = {
'Congé payé': 'CP',
'RTT': 'RTT',
'Récupération': 'Récup',
'Congé maladie': 'ABS',
'Formation': 'Formation'
};
if (request.type) {
const types = request.type.split(', ').map(t => typeMapping[t] || t);
setSelectedTypes(types);
console.log('✅ Types initialisés:', types);
}
// Calculer jours ouvrés
if (request.startDate && request.endDate) {
const days = calculateBusinessDays(request.startDate, request.endDate);
setBusinessDays(days.businessDays);
setSaturdayCount(days.saturdayCount);
}
}
}, [isOpen, request]);
// Charger les compteurs
useEffect(() => {
if (isOpen && userId) {
loadCounters();
}
}, [isOpen, userId]);
// ========================================
// FONCTIONS UTILITAIRES
// ========================================
const calculateBusinessDays = (start, end) => {
const startD = new Date(start);
const endD = new Date(end);
let workingDays = 0;
let saturdays = 0;
const current = new Date(startD);
while (current <= endD) {
const dayOfWeek = current.getDay();
if (dayOfWeek === 6) {
saturdays++;
} else if (dayOfWeek !== 0) {
workingDays++;
}
current.setDate(current.getDate() + 1);
}
return { businessDays: workingDays, saturdayCount: saturdays };
};
const loadCounters = async () => {
setIsLoadingCounters(true);
try {
const response = await fetch(`/api/getDetailedLeaveCounters?user_id=${userId}`);
const data = await response.json();
if (data.success) {
setCountersData(data);
console.log('✅ Compteurs chargés:', data);
}
} catch (error) {
console.error('❌ Erreur chargement compteurs:', error);
} finally {
setIsLoadingCounters(false);
}
};
// ========================================
// GESTION DES TYPES
// ========================================
const handleTypeToggle = (typeId) => {
if (typeId === 'ABS' && selectedTypes.length > 0 && !selectedTypes.includes('ABS')) {
alert('⚠️ L\'arrêt maladie ne peut pas être combiné avec d\'autres types');
return;
}
if (selectedTypes.includes('ABS') && typeId !== 'ABS') {
alert('⚠️ L\'arrêt maladie ne peut pas être combiné avec d\'autres types');
return;
}
setSelectedTypes(prev => {
if (prev.includes(typeId)) {
const newTypes = prev.filter(t => t !== typeId);
const newRep = { ...repartition };
delete newRep[typeId];
setRepartition(newRep);
const newPeriodes = { ...periodeSelection };
delete newPeriodes[typeId];
setPeriodeSelection(newPeriodes);
return newTypes;
} else {
return [...prev, typeId];
}
});
};
// ========================================
// GESTION RÉPARTITION
// ========================================
const handleRepartitionChange = (typeId, value) => {
const numValue = parseFloat(value) || 0;
const maxValue = businessDays;
if (numValue > maxValue) {
alert(`Maximum ${maxValue} jours`);
return;
}
setRepartition(prev => ({
...prev,
[typeId]: numValue
}));
};
const handlePeriodeChange = (typeId, periode) => {
setPeriodeSelection(prev => ({
...prev,
[typeId]: periode
}));
// Calcul automatique si un seul type
if (selectedTypes.length === 1 && startDate === endDate) {
if (periode === 'Matin' || periode === 'Après-midi') {
setRepartition({ [typeId]: 0.5 });
} else {
setRepartition({ [typeId]: businessDays });
}
}
};
// ========================================
// GESTION FICHIERS MÉDICAUX
// ========================================
const handleFileSelect = (e) => {
const files = Array.from(e.target.files);
addFiles(files);
};
const handleDrop = (e) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
addFiles(files);
};
const addFiles = (files) => {
const validFiles = files.filter(file => {
const isValidType = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'].includes(file.type);
const isValidSize = file.size <= 5 * 1024 * 1024;
if (!isValidType) {
alert(`${file.name}: Type non autorisé (PDF, JPG, PNG uniquement)`);
return false;
}
if (!isValidSize) {
alert(`${file.name}: Taille max 5MB`);
return false;
}
return true;
});
setMedicalDocuments(prev => [...prev, ...validFiles]);
};
const removeFile = (index) => {
setMedicalDocuments(prev => prev.filter((_, i) => i !== index));
};
// ========================================
// VALIDATION
// ========================================
const validateForm = () => {
const errors = [];
// Dates
if (!startDate || !endDate) {
errors.push('Les dates sont obligatoires');
} else if (new Date(startDate) > new Date(endDate)) {
errors.push('La date de fin doit être après la date de début');
}
// Types
if (selectedTypes.length === 0) {
errors.push('Sélectionnez au moins un type de congé');
}
// Documents pour ABS
if (selectedTypes.includes('ABS') && medicalDocuments.length === 0) {
errors.push('Un justificatif médical est obligatoire pour un arrêt maladie');
}
// Répartition
if (selectedTypes.length > 1) {
const total = Object.values(repartition).reduce((sum, val) => sum + val, 0);
if (Math.abs(total - businessDays) > 0.01) {
errors.push(`La répartition (${total.toFixed(1)}j) ne correspond pas au total (${businessDays}j)`);
}
}
// Compteurs (si chargés)
if (countersData?.data?.totalDisponible) {
const safeCounters = {
availableCP: countersData.data.cpN?.solde || 0,
availableRTT: countersData.data.rttN?.solde || 0,
availableRecup: countersData.data.recupN?.solde || 0
};
selectedTypes.forEach(type => {
if (type === 'CP') {
const cpDemande = selectedTypes.length === 1 ? businessDays : (repartition[type] || 0);
if (cpDemande > safeCounters.availableCP) {
errors.push(`Solde CP insuffisant (${safeCounters.availableCP.toFixed(1)}j disponibles)`);
}
}
if (type === 'RTT') {
const rttDemande = selectedTypes.length === 1 ? businessDays : (repartition[type] || 0);
if (rttDemande > safeCounters.availableRTT) {
errors.push(`Solde RTT insuffisant (${safeCounters.availableRTT.toFixed(1)}j disponibles)`);
}
}
if (type === 'Récup') {
const recupDemande = selectedTypes.length === 1 ? businessDays : (repartition[type] || 0);
if (recupDemande > safeCounters.availableRecup) {
errors.push(`Solde Récup insuffisant (${safeCounters.availableRecup.toFixed(1)}j disponibles)`);
}
}
});
}
setValidationErrors(errors);
return errors.length === 0;
};
// ========================================
// SOUMISSION
// ========================================
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsSubmitting(true);
setSubmitMessage({ type: '', text: '' });
try {
const formDataToSend = new FormData();
// ⭐ CHAMPS REQUIS PAR LE BACKEND
formDataToSend.append('requestId', request.id.toString());
formDataToSend.append('userId', userId.toString());
formDataToSend.append('userEmail', userEmail);
formDataToSend.append('userName', userName);
formDataToSend.append('accessToken', accessToken || '');
// ⭐ DATES
formDataToSend.append('DateDebut', startDate);
formDataToSend.append('DateFin', endDate);
formDataToSend.append('startDate', startDate);
formDataToSend.append('endDate', endDate);
// ⭐ COMMENTAIRE
formDataToSend.append('Commentaire', reason || 'Aucun commentaire');
formDataToSend.append('reason', reason || 'Aucun commentaire');
// ⭐ CALCUL NOMBRE DE JOURS TOTAL
let totalJoursToSend = businessDays;
if (selectedTypes.length === 1 && startDate === endDate) {
const type = selectedTypes[0];
const periode = periodeSelection[type];
if ((type === 'CP' || type === 'RTT' || type === 'Récup') &&
(periode === 'Matin' || periode === 'Après-midi')) {
totalJoursToSend = 0.5;
}
}
formDataToSend.append('NombreJours', totalJoursToSend.toString());
formDataToSend.append('businessDays', totalJoursToSend.toString());
// ⭐ RÉPARTITION (CORRECTION ICI)
const repartitionArray = selectedTypes.map(type => {
let nombreJours;
let periodeJournee = 'Journée entière';
if (selectedTypes.length === 1) {
const periode = periodeSelection[type] || 'Journée entière';
if ((type === 'CP' || type === 'RTT' || type === 'Récup') &&
startDate === endDate &&
(periode === 'Matin' || periode === 'Après-midi')) {
nombreJours = 0.5;
periodeJournee = periode;
} else {
nombreJours = businessDays;
}
} else {
nombreJours = repartition[type] || 0;
periodeJournee = periodeSelection[type] || 'Journée entière';
}
return {
TypeConge: type,
NombreJours: nombreJours,
PeriodeJournee: ['CP', 'RTT', 'Récup'].includes(type) ? periodeJournee : 'Journée entière'
};
});
// ⭐ STRINGIFIER LA RÉPARTITION (CRITIQUE POUR FORMDATA)
formDataToSend.append('Repartition', JSON.stringify(repartitionArray));
// ⭐ TYPE DE CONGÉ (pour compatibilité backend)
const leaveTypeMapping = {
'CP': 1,
'RTT': 2,
'ABS': 3,
'Formation': 4,
'Récup': 5
};
const leaveTypeId = leaveTypeMapping[selectedTypes[0]] || 1;
formDataToSend.append('leaveType', leaveTypeId.toString());
// Documents médicaux EN DERNIER
if (medicalDocuments.length > 0) {
medicalDocuments.forEach((file) => {
formDataToSend.append('medicalDocuments', file);
});
}
console.log('📤 Envoi modification demande...');
console.log('📊 Répartition envoyée:', JSON.stringify(repartitionArray, null, 2));
for (let pair of formDataToSend.entries()) {
if (pair[0] !== 'medicalDocuments') {
console.log(pair[0], ':', pair[1]);
}
}
const response = await fetch('/api/updateRequest', {
method: 'POST',
body: formDataToSend
});
const responseText = await response.text();
console.log('📥 Réponse brute:', responseText);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status} - ${responseText}`);
}
let data;
try {
data = JSON.parse(responseText);
} catch (parseError) {
console.error('❌ Erreur parsing JSON:', parseError);
throw new Error('Réponse serveur invalide: ' + responseText);
}
if (data.success) {
setSubmitMessage({
type: 'success',
text: '✅ Demande modifiée avec succès !'
});
setTimeout(() => {
onRequestUpdated();
onClose();
}, 1500);
} else {
setSubmitMessage({
type: 'error',
text: `${data.message || 'Erreur lors de la modification'}`
});
}
} catch (error) {
console.error('❌ Erreur:', error);
setSubmitMessage({
type: 'error',
text: `${error.message || 'Une erreur est survenue'}`
});
} finally {
setIsSubmitting(false);
}
};
// ========================================
// RECALCUL AUTO JOURS OUVRÉS
// ========================================
useEffect(() => {
if (startDate && endDate) {
const days = calculateBusinessDays(startDate, endDate);
setBusinessDays(days.businessDays);
setSaturdayCount(days.saturdayCount);
// Réinitialiser répartition si changement de dates
if (selectedTypes.length === 1) {
const type = selectedTypes[0];
setRepartition({ [type]: days.businessDays });
}
}
}, [startDate, endDate]);
// ========================================
// RENDER
// ========================================
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
{/* HEADER */}
<div className="sticky top-0 bg-white border-b px-6 py-4 flex justify-between items-center">
<h2 className="text-2xl font-bold text-gray-800">
Modifier la demande
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-full transition"
>
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* DATES */}
<div className="grid grid-cols-1 md: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"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date de fin *
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
min={startDate}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
required
/>
</div>
</div>
{/* RÉSUMÉ PÉRIODE */}
{businessDays > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-blue-800">
<span className="font-semibold">📅 Période :</span>
<span>{businessDays} jour(s) ouvré(s)</span>
{saturdayCount > 0 && (
<span className="text-sm">+ {saturdayCount} samedi(s)</span>
)}
</div>
</div>
)}
{/* TYPES DE CONGÉ */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Types de congé *
</label>
{isLoadingCounters ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-3 text-gray-600">Chargement des compteurs...</span>
</div>
) : (
<div className="space-y-2">
{availableTypes.map(type => {
const isSelected = selectedTypes.includes(type.id);
let counterDisplay = null;
if (countersData?.data) {
if (type.id === 'CP') {
const solde = countersData.data.cpN?.solde || 0;
counterDisplay = `${solde.toFixed(1)}j`;
} else if (type.id === 'RTT') {
const solde = countersData.data.rttN?.solde || 0;
counterDisplay = `${solde.toFixed(1)}j`;
} else if (type.id === 'Récup') {
const solde = countersData.data.recupN?.solde || 0;
counterDisplay = `${solde.toFixed(1)}j`;
}
}
return (
<label
key={type.id}
className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${isSelected
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleTypeToggle(type.id)}
className="w-5 h-5"
/>
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: type.color }}
/>
<span className="font-medium">{type.label}</span>
{counterDisplay && (
<span className="ml-auto text-sm text-gray-600 font-mono">
{counterDisplay}
</span>
)}
</label>
);
})}
</div>
)}
</div>
{/* RÉPARTITION SI MULTI-TYPES */}
{selectedTypes.length > 1 && (
<div className="bg-gray-50 border rounded-lg p-4 space-y-3">
<h3 className="font-semibold text-gray-800">📊 Répartition des jours</h3>
{selectedTypes.map(type => (
<div key={type} className="flex items-center gap-3">
<label className="w-32 font-medium">{type}</label>
<input
type="number"
step="0.5"
min="0"
max={businessDays}
value={repartition[type] || 0}
onChange={(e) => handleRepartitionChange(type, e.target.value)}
className="w-24 px-3 py-2 border rounded-lg"
/>
<span className="text-sm text-gray-600">jour(s)</span>
{/* Période */}
{(type === 'CP' || type === 'RTT' || type === 'Récup') && (
<div className="ml-auto flex gap-2">
{['Matin', 'Après-midi', 'Journée entière'].map(p => (
<button
key={p}
type="button"
onClick={() => handlePeriodeChange(type, p)}
className={`px-3 py-1 text-sm rounded ${periodeSelection[type] === p
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
{p}
</button>
))}
</div>
)}
</div>
))}
</div>
)}
{/* PÉRIODE (UN SEUL TYPE) */}
{selectedTypes.length === 1 && startDate === endDate &&
(selectedTypes[0] === 'CP' || selectedTypes[0] === 'RTT' || selectedTypes[0] === 'Récup') && (
<div className="bg-gray-50 border rounded-lg p-4">
<h3 className="font-semibold text-gray-800 mb-3"> Période de la journée</h3>
<div className="flex gap-3">
{['Matin', 'Après-midi', 'Journée entière'].map(p => (
<button
key={p}
type="button"
onClick={() => handlePeriodeChange(selectedTypes[0], p)}
className={`flex-1 py-2 px-4 rounded-lg font-medium transition ${periodeSelection[selectedTypes[0]] === p
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{p}
</button>
))}
</div>
</div>
)}
{/* DOCUMENTS MÉDICAUX */}
{selectedTypes.includes('ABS') && (
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
Documents médicaux *
</label>
<div
onDrop={handleDrop}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
className={`border-2 border-dashed rounded-lg p-6 text-center transition ${isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
}`}
>
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-3" />
<p className="text-sm text-gray-600 mb-2">
Glissez vos fichiers ici ou cliquez pour sélectionner
</p>
<input
type="file"
accept=".pdf,.jpg,.jpeg,.png"
multiple
onChange={handleFileSelect}
className="hidden"
id="medical-upload"
/>
<label
htmlFor="medical-upload"
className="inline-block px-4 py-2 bg-blue-500 text-white rounded-lg cursor-pointer hover:bg-blue-600"
>
Choisir des fichiers
</label>
</div>
{medicalDocuments.length > 0 && (
<div className="space-y-2">
{medicalDocuments.map((file, index) => (
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg">
{file.type === 'application/pdf' ? (
<FileText className="text-red-500" size={24} />
) : (
<ImageIcon className="text-green-500" size={24} />
)}
<span className="flex-1 text-sm truncate">{file.name}</span>
<span className="text-xs text-gray-500">
{(file.size / 1024).toFixed(0)} KB
</span>
<button
type="button"
onClick={() => removeFile(index)}
className="p-1 hover:bg-red-100 rounded text-red-500"
>
<Trash2 size={18} />
</button>
</div>
))}
</div>
)}
</div>
)}
{/* COMMENTAIRE */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Commentaire
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows="3"
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Motif de la modification..."
/>
</div>
{/* ERREURS DE VALIDATION */}
{validationErrors.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex gap-2">
<AlertCircle className="text-red-500 flex-shrink-0" size={20} />
<div className="space-y-1">
{validationErrors.map((err, i) => (
<p key={i} className="text-sm text-red-700">{err}</p>
))}
</div>
</div>
</div>
)}
{/* MESSAGE SOUMISSION */}
{submitMessage.text && (
<div className={`p-4 rounded-lg ${submitMessage.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{submitMessage.text}
</div>
)}
{/* BOUTONS */}
<div className="flex gap-3 justify-end pt-4 border-t">
<button
type="button"
onClick={onClose}
className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
disabled={isSubmitting}
>
Annuler
</button>
<button
type="submit"
disabled={isSubmitting || isLoadingCounters}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Enregistrement...' : 'Enregistrer les modifications'}
</button>
</div>
</form>
</div>
</div>
);
};
export default EditLeaveRequestModal;