first_commit
25
GTA_P_V2/project/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.env
|
||||
53
GTA_P_V2/project/DockerfileGTA.frontend
Normal file
@@ -0,0 +1,53 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Install all dependencies
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Create vite.config.js with correct proxy settings
|
||||
RUN cat > vite.config.js << 'VITECONFIG'
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 90,
|
||||
strictPort: true,
|
||||
allowedHosts: ['mygta-dev.ensup-adm.net', 'localhost'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:3004',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: (proxy, options) => {
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.log('Proxy error:', err);
|
||||
});
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
console.log('Proxying:', req.method, req.url, '-> http://backend:3004');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
VITECONFIG
|
||||
|
||||
EXPOSE 90
|
||||
|
||||
CMD ["npx", "vite", "--host", "0.0.0.0", "--port", "90"]
|
||||
16
GTA_P_V2/project/convert-cert-docker.ps1
Normal file
@@ -0,0 +1,16 @@
|
||||
# Variables
|
||||
$PFX_PATH = "C:\Users\oimer\.aspnet\https\aspnetapp.pfx"
|
||||
$PASSWORD = "tGTF2025"
|
||||
|
||||
Write-Host "Conversion du certificat via Docker..." -ForegroundColor Yellow
|
||||
|
||||
# Convertir en certificat (.crt)
|
||||
docker run --rm -v C:\Users\oimer\.aspnet\https:/certs alpine/openssl pkcs12 -in /certs/aspnetapp.pfx -clcerts -nokeys -out /certs/aspnetapp.crt -passin pass:$PASSWORD
|
||||
|
||||
# Convertir en clé privée (.key)
|
||||
docker run --rm -v C:\Users\oimer\.aspnet\https:/certs alpine/openssl pkcs12 -in /certs/aspnetapp.pfx -nocerts -nodes -out /certs/aspnetapp.key -passin pass:$PASSWORD
|
||||
|
||||
Write-Host "`n✓ Certificats convertis avec succès!" -ForegroundColor Green
|
||||
Write-Host "Fichiers créés:" -ForegroundColor Cyan
|
||||
Write-Host " - C:\Users\oimer\.aspnet\https\aspnetapp.crt" -ForegroundColor White
|
||||
Write-Host " - C:\Users\oimer\.aspnet\https\aspnetapp.key" -ForegroundColor White
|
||||
13
GTA_P_V2/project/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/GA.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GTA</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8524
GTA_P_V2/project/package-lock.json
generated
Normal file
39
GTA_P_V2/project/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "gestion-conges-jsx",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^4.19.0",
|
||||
"@azure/msal-react": "^3.0.17",
|
||||
"axios": "^1.12.2",
|
||||
"cors": "^2.8.5",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.344.0",
|
||||
"multer": "^2.0.2",
|
||||
"mysql2": "^3.15.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-joyride": "^2.9.3",
|
||||
"react-router-dom": "^7.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"jest": "^30.1.1",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.4.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
6
GTA_P_V2/project/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
24
GTA_P_V2/project/public/Backend/DockerfileGTA.backend
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
# Install required tools
|
||||
RUN apk add --no-cache curl mysql-client python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better caching
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --production
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p /app/uploads/medical
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 3004
|
||||
|
||||
# Start the server
|
||||
CMD ["node", "server.js"]
|
||||
27
GTA_P_V2/project/public/Backend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "gta-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "GTA Backend API",
|
||||
"main": "server.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"mssql": "^10.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@azure/identity": "^4.0.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"axios": "^1.6.0",
|
||||
"node-cron": "^3.0.3"
|
||||
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
570
GTA_P_V2/project/public/Backend/server-test.js
Normal file
@@ -0,0 +1,570 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import sql from 'mssql';
|
||||
import axios from 'axios';
|
||||
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
app.use(cors({ origin: '*' }));
|
||||
app.use(express.json());
|
||||
|
||||
// Configuration Azure AD
|
||||
const AZURE_CONFIG = {
|
||||
tenantId: '9840a2a0-6ae1-4688-b03d-d2ec291be0f9',
|
||||
clientId: '4bb4cc24-bac3-427c-b02c-5d14fc67b561',
|
||||
clientSecret: 'gvf8Q~545Bafn8yYsgjW~QG_P1lpzaRe6gJNgb2t',
|
||||
groupId: 'c1ea877c-6bca-4f47-bfad-f223640813a0'
|
||||
};
|
||||
|
||||
// Configuration SQL Server
|
||||
const dbConfig = {
|
||||
server: '192.168.0.3',
|
||||
user: 'gta_app',
|
||||
password: 'GTA2025!Secure',
|
||||
database: 'GTA',
|
||||
port: 1433,
|
||||
options: {
|
||||
encrypt: true,
|
||||
trustServerCertificate: true,
|
||||
enableArithAbort: true,
|
||||
connectTimeout: 60000,
|
||||
requestTimeout: 60000
|
||||
},
|
||||
pool: {
|
||||
max: 10,
|
||||
min: 0,
|
||||
idleTimeoutMillis: 30000
|
||||
}
|
||||
};
|
||||
|
||||
// Créer le pool de connexions
|
||||
const pool = new sql.ConnectionPool(dbConfig);
|
||||
|
||||
// Connexion au démarrage
|
||||
pool.connect()
|
||||
.then(() => {
|
||||
console.log('✅ Connecté à SQL Server');
|
||||
console.log(` Base: ${dbConfig.database}@${dbConfig.server}`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('❌ Erreur connexion SQL Server:', err.message);
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// WRAPPER POUR COMPATIBILITÉ (style MySQL)
|
||||
// ========================================
|
||||
pool.query = async function (queryText, params = []) {
|
||||
if (!pool.connected) {
|
||||
await pool.connect();
|
||||
}
|
||||
|
||||
const request = pool.request();
|
||||
|
||||
// Ajouter les paramètres
|
||||
params.forEach((value, index) => {
|
||||
request.input(`param${index}`, value);
|
||||
});
|
||||
|
||||
// Remplacer ? par @param0, @param1, etc.
|
||||
let parameterizedQuery = queryText;
|
||||
let paramIndex = 0;
|
||||
parameterizedQuery = parameterizedQuery.replace(/\?/g, () => `@param${paramIndex++}`);
|
||||
|
||||
// Conversion LIMIT → TOP
|
||||
parameterizedQuery = parameterizedQuery.replace(
|
||||
/LIMIT\s+(\d+)/gi,
|
||||
(match, limit) => {
|
||||
return parameterizedQuery.includes('SELECT')
|
||||
? parameterizedQuery.replace(/SELECT/i, `SELECT TOP ${limit}`)
|
||||
: '';
|
||||
}
|
||||
);
|
||||
|
||||
const result = await request.query(parameterizedQuery);
|
||||
return result.recordset || [];
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 🔑 FONCTION TOKEN MICROSOFT GRAPH
|
||||
// ========================================
|
||||
async function getGraphToken() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: AZURE_CONFIG.clientId,
|
||||
client_secret: AZURE_CONFIG.clientSecret,
|
||||
scope: 'https://graph.microsoft.com/.default'
|
||||
});
|
||||
|
||||
const response = await axios.post(
|
||||
`https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`,
|
||||
params.toString(),
|
||||
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
||||
);
|
||||
|
||||
return response.data.access_token;
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur obtention token:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 🔄 FONCTION SYNCHRONISATION ENTRA ID
|
||||
// ========================================
|
||||
async function syncEntraIdUsers() {
|
||||
const syncResults = {
|
||||
processed: 0,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
deactivated: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('\n🔄 === DÉBUT SYNCHRONISATION ENTRA ID ===');
|
||||
|
||||
// 1️⃣ Obtenir le token
|
||||
const accessToken = await getGraphToken();
|
||||
if (!accessToken) {
|
||||
console.error('❌ Impossible d\'obtenir le token');
|
||||
return syncResults;
|
||||
}
|
||||
console.log('✅ Token obtenu');
|
||||
|
||||
// 2️⃣ Récupérer le groupe
|
||||
const groupResponse = await axios.get(
|
||||
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||
);
|
||||
const groupName = groupResponse.data.displayName;
|
||||
console.log(`📋 Groupe : ${groupName}`);
|
||||
|
||||
// 3️⃣ Récupérer tous les membres avec pagination
|
||||
let allAzureMembers = [];
|
||||
let nextLink = `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}/members?$select=id,givenName,surname,mail,department,jobTitle,officeLocation,accountEnabled&$top=999`;
|
||||
|
||||
console.log('📥 Récupération des membres...');
|
||||
while (nextLink) {
|
||||
const membersResponse = await axios.get(nextLink, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
});
|
||||
allAzureMembers = allAzureMembers.concat(membersResponse.data.value);
|
||||
nextLink = membersResponse.data['@odata.nextLink'];
|
||||
|
||||
if (nextLink) {
|
||||
console.log(` 📄 ${allAzureMembers.length} membres récupérés...`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ ${allAzureMembers.length} membres trouvés`);
|
||||
|
||||
// 4️⃣ Filtrer les membres valides
|
||||
const validMembers = allAzureMembers.filter(m => {
|
||||
if (!m.mail || m.mail.trim() === '') return false;
|
||||
if (m.accountEnabled === false) return false;
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(m.mail);
|
||||
});
|
||||
|
||||
console.log(`✅ ${validMembers.length} membres valides`);
|
||||
|
||||
// 5️⃣ Traitement avec transaction
|
||||
const transaction = new sql.Transaction(pool);
|
||||
await transaction.begin();
|
||||
|
||||
try {
|
||||
const azureEmails = new Set();
|
||||
validMembers.forEach(m => {
|
||||
azureEmails.add(m.mail.toLowerCase().trim());
|
||||
});
|
||||
|
||||
console.log('\n📝 Traitement des utilisateurs...');
|
||||
|
||||
// 6️⃣ Pour chaque membre
|
||||
for (const m of validMembers) {
|
||||
try {
|
||||
const emailClean = m.mail.toLowerCase().trim();
|
||||
syncResults.processed++;
|
||||
|
||||
// Vérifier existence
|
||||
const request = new sql.Request(transaction);
|
||||
request.input('email', sql.NVarChar, emailClean);
|
||||
|
||||
const result = await request.query(`
|
||||
SELECT id, email, entraUserId, actif
|
||||
FROM CollaborateurAD
|
||||
WHERE LOWER(email) = LOWER(@email)
|
||||
`);
|
||||
|
||||
if (result.recordset.length > 0) {
|
||||
// MISE À JOUR
|
||||
const updateRequest = new sql.Request(transaction);
|
||||
updateRequest.input('entraUserId', sql.NVarChar, m.id);
|
||||
updateRequest.input('prenom', sql.NVarChar, m.givenName || '');
|
||||
updateRequest.input('nom', sql.NVarChar, m.surname || '');
|
||||
updateRequest.input('departement', sql.NVarChar, m.department || '');
|
||||
updateRequest.input('fonction', sql.NVarChar, m.jobTitle || '');
|
||||
updateRequest.input('campus', sql.NVarChar, m.officeLocation || '');
|
||||
updateRequest.input('email', sql.NVarChar, emailClean);
|
||||
|
||||
await updateRequest.query(`
|
||||
UPDATE CollaborateurAD
|
||||
SET
|
||||
entraUserId = @entraUserId,
|
||||
prenom = @prenom,
|
||||
nom = @nom,
|
||||
departement = @departement,
|
||||
fonction = @fonction,
|
||||
campus = @campus,
|
||||
actif = 1
|
||||
WHERE LOWER(email) = LOWER(@email)
|
||||
`);
|
||||
|
||||
syncResults.updated++;
|
||||
console.log(` ✓ Mis à jour : ${emailClean}`);
|
||||
|
||||
} else {
|
||||
// INSERTION
|
||||
const insertRequest = new sql.Request(transaction);
|
||||
insertRequest.input('entraUserId', sql.NVarChar, m.id);
|
||||
insertRequest.input('prenom', sql.NVarChar, m.givenName || '');
|
||||
insertRequest.input('nom', sql.NVarChar, m.surname || '');
|
||||
insertRequest.input('email', sql.NVarChar, emailClean);
|
||||
insertRequest.input('departement', sql.NVarChar, m.department || '');
|
||||
insertRequest.input('fonction', sql.NVarChar, m.jobTitle || '');
|
||||
insertRequest.input('campus', sql.NVarChar, m.officeLocation || '');
|
||||
|
||||
await insertRequest.query(`
|
||||
INSERT INTO CollaborateurAD
|
||||
(entraUserId, prenom, nom, email, departement, fonction, campus, role, SocieteId, actif, dateCreation, TypeContrat)
|
||||
VALUES (@entraUserId, @prenom, @nom, @email, @departement, @fonction, @campus, 'Collaborateur', 1, 1, GETDATE(), '37h')
|
||||
`);
|
||||
|
||||
syncResults.inserted++;
|
||||
console.log(` ✓ Créé : ${emailClean}`);
|
||||
}
|
||||
|
||||
} catch (userError) {
|
||||
syncResults.errors.push({
|
||||
email: m.mail,
|
||||
error: userError.message
|
||||
});
|
||||
console.error(` ❌ Erreur ${m.mail}:`, userError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 7️⃣ DÉSACTIVATION des comptes absents
|
||||
console.log('\n🔍 Désactivation des comptes obsolètes...');
|
||||
|
||||
if (azureEmails.size > 0) {
|
||||
const activeEmailsList = Array.from(azureEmails).map(e => `'${e}'`).join(',');
|
||||
|
||||
const deactivateRequest = new sql.Request(transaction);
|
||||
const deactivateResult = await deactivateRequest.query(`
|
||||
UPDATE CollaborateurAD
|
||||
SET actif = 0
|
||||
WHERE
|
||||
email IS NOT NULL
|
||||
AND email != ''
|
||||
AND LOWER(email) NOT IN (${activeEmailsList})
|
||||
AND actif = 1
|
||||
`);
|
||||
|
||||
syncResults.deactivated = deactivateResult.rowsAffected[0];
|
||||
console.log(` ✓ ${syncResults.deactivated} compte(s) désactivé(s)`);
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
console.log('\n📊 === RÉSUMÉ ===');
|
||||
console.log(` Groupe: ${groupName}`);
|
||||
console.log(` Total Entra: ${allAzureMembers.length}`);
|
||||
console.log(` Valides: ${validMembers.length}`);
|
||||
console.log(` Traités: ${syncResults.processed}`);
|
||||
console.log(` Créés: ${syncResults.inserted}`);
|
||||
console.log(` Mis à jour: ${syncResults.updated}`);
|
||||
console.log(` Désactivés: ${syncResults.deactivated}`);
|
||||
console.log(` Erreurs: ${syncResults.errors.length}`);
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ ERREUR SYNCHRONISATION:', error.message);
|
||||
}
|
||||
|
||||
return syncResults;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 📡 ROUTES API
|
||||
// ========================================
|
||||
|
||||
// Route test connexion
|
||||
app.get('/api/db-status', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT COUNT(*) AS count FROM CollaborateurAD', []);
|
||||
const collaboratorCount = result[0]?.count || 0;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Connexion SQL Server OK',
|
||||
collaboratorCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur connexion:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Erreur connexion base',
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route sync unitaire
|
||||
app.post('/api/initial-sync', async (req, res) => {
|
||||
try {
|
||||
const email = (req.body.mail || req.body.userPrincipalName)?.toLowerCase().trim();
|
||||
const entraUserId = req.body.id;
|
||||
|
||||
if (!email) {
|
||||
return res.json({ success: false, message: 'Email manquant' });
|
||||
}
|
||||
|
||||
console.log(`\n🔄 Sync utilisateur : ${email}`);
|
||||
|
||||
const transaction = new sql.Transaction(pool);
|
||||
await transaction.begin();
|
||||
|
||||
try {
|
||||
// Vérifier existence
|
||||
const checkRequest = new sql.Request(transaction);
|
||||
checkRequest.input('email', sql.NVarChar, email);
|
||||
|
||||
const existing = await checkRequest.query(`
|
||||
SELECT id, email, actif
|
||||
FROM CollaborateurAD
|
||||
WHERE LOWER(email) = LOWER(@email)
|
||||
`);
|
||||
|
||||
if (existing.recordset.length > 0) {
|
||||
// UPDATE
|
||||
const updateRequest = new sql.Request(transaction);
|
||||
updateRequest.input('collaborateurADId', sql.NVarChar, entraUserId);
|
||||
updateRequest.input('prenom', sql.NVarChar, req.body.givenName || '');
|
||||
updateRequest.input('nom', sql.NVarChar, req.body.surname || '');
|
||||
updateRequest.input('departement', sql.NVarChar, req.body.department || '');
|
||||
updateRequest.input('fonction', sql.NVarChar, req.body.jobTitle || '');
|
||||
updateRequest.input('campus', sql.NVarChar, req.body.officeLocation || '');
|
||||
updateRequest.input('email', sql.NVarChar, email);
|
||||
updateRequest.input('dateMaj', sql.DateTime, new Date());
|
||||
|
||||
await updateRequest.query(`
|
||||
UPDATE CollaborateurAD
|
||||
SET
|
||||
CollaborateurADId = @collaborateurADId,
|
||||
prenom = @prenom,
|
||||
nom = @nom,
|
||||
departement = @departement,
|
||||
fonction = @fonction,
|
||||
campus = @campus,
|
||||
actif = 1,
|
||||
dateMiseAJour = @dateMaj
|
||||
WHERE LOWER(email) = LOWER(@email)
|
||||
`);
|
||||
|
||||
console.log(` ✅ Mis à jour : ${email}`);
|
||||
} else {
|
||||
// INSERT
|
||||
const insertRequest = new sql.Request(transaction);
|
||||
insertRequest.input('collaborateurADId', sql.NVarChar, entraUserId);
|
||||
insertRequest.input('prenom', sql.NVarChar, req.body.givenName || '');
|
||||
insertRequest.input('nom', sql.NVarChar, req.body.surname || '');
|
||||
insertRequest.input('email', sql.NVarChar, email);
|
||||
insertRequest.input('departement', sql.NVarChar, req.body.department || '');
|
||||
insertRequest.input('fonction', sql.NVarChar, req.body.jobTitle || '');
|
||||
insertRequest.input('campus', sql.NVarChar, req.body.officeLocation || '');
|
||||
insertRequest.input('dateCreation', sql.DateTime, new Date());
|
||||
insertRequest.input('dateMaj', sql.DateTime, new Date());
|
||||
|
||||
await insertRequest.query(`
|
||||
INSERT INTO CollaborateurAD
|
||||
(CollaborateurADId, prenom, nom, email, departement, fonction, campus, service, societe, actif, dateCreation, dateMiseAJour)
|
||||
VALUES (@collaborateurADId, @prenom, @nom, @email, @departement, @fonction, @campus, NULL, NULL, 1, @dateCreation, @dateMaj)
|
||||
`);
|
||||
|
||||
console.log(` ✅ Créé : ${email}`);
|
||||
}
|
||||
|
||||
// Récupérer données
|
||||
const getUserRequest = new sql.Request(transaction);
|
||||
getUserRequest.input('email', sql.NVarChar, email);
|
||||
|
||||
const userData = await getUserRequest.query(`
|
||||
SELECT id as localUserId, email, prenom, nom, fonction, departement
|
||||
FROM CollaborateurAD
|
||||
WHERE LOWER(email) = LOWER(@email)
|
||||
`);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
if (userData.recordset.length === 0) {
|
||||
throw new Error('Utilisateur introuvable après sync');
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Sync réussie',
|
||||
localUserId: userData.recordset[0].localUserId,
|
||||
user: userData.recordset[0]
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur sync:', error);
|
||||
res.json({
|
||||
success: false,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route check groups
|
||||
app.post('/api/check-user-groups', async (req, res) => {
|
||||
try {
|
||||
const { userPrincipalName } = req.body;
|
||||
|
||||
if (!userPrincipalName) {
|
||||
return res.json({ authorized: false, message: 'Email manquant' });
|
||||
}
|
||||
|
||||
const users = await pool.query(
|
||||
'SELECT id, email, prenom, nom, actif FROM CollaborateurAD WHERE email = ?',
|
||||
[userPrincipalName]
|
||||
);
|
||||
|
||||
if (users.length > 0) {
|
||||
const user = users[0];
|
||||
|
||||
if (user.actif === 0) {
|
||||
return res.json({ authorized: false, message: 'Compte désactivé' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
authorized: true,
|
||||
localUserId: user.id,
|
||||
user: user
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
authorized: true,
|
||||
message: 'Sera créé au login'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur check:', error);
|
||||
res.json({ authorized: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Route sync complète manuelle
|
||||
app.post('/api/sync-all', async (req, res) => {
|
||||
try {
|
||||
console.log('🚀 Sync complète manuelle...');
|
||||
const results = await
|
||||
IdUsers();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Sync terminée',
|
||||
stats: results
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route diagnostic
|
||||
app.get('/api/diagnostic-sync', async (req, res) => {
|
||||
try {
|
||||
const totalDB = await pool.query(
|
||||
'SELECT COUNT(*) as total, SUM(CASE WHEN actif = 1 THEN 1 ELSE 0 END) as actifs FROM CollaborateurAD',
|
||||
[]
|
||||
);
|
||||
|
||||
const sansEmail = await pool.query(
|
||||
'SELECT COUNT(*) as total FROM CollaborateurAD WHERE email IS NULL OR email = \'\'',
|
||||
[]
|
||||
);
|
||||
|
||||
const derniers = await pool.query(
|
||||
'SELECT TOP 10 id, prenom, nom, email, CollaborateurADId, actif FROM CollaborateurAD ORDER BY id DESC',
|
||||
[]
|
||||
);
|
||||
|
||||
// Test Entra
|
||||
let entraStatus = { connected: false };
|
||||
try {
|
||||
const token = await getGraphToken();
|
||||
if (token) {
|
||||
const groupResponse = await axios.get(
|
||||
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
entraStatus = {
|
||||
connected: true,
|
||||
groupName: groupResponse.data.displayName
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
entraStatus.error = err.message;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
database: {
|
||||
total: totalDB[0]?.total || 0,
|
||||
actifs: totalDB[0]?.actifs || 0,
|
||||
sansEmail: sansEmail[0]?.total || 0
|
||||
},
|
||||
entraId: entraStatus,
|
||||
derniers_utilisateurs: derniers
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// 🚀 DÉMARRAGE
|
||||
// ========================================
|
||||
app.listen(PORT, "0.0.0.0", async () => {
|
||||
console.log("✅ ==========================================");
|
||||
console.log(" SERVEUR TEST DÉMARRÉ");
|
||||
console.log(" Port:", PORT);
|
||||
console.log(` Base SQL Server: ${dbConfig.database}@${dbConfig.server}`);
|
||||
console.log("==========================================");
|
||||
|
||||
// Sync auto après 5 secondes
|
||||
setTimeout(async () => {
|
||||
console.log("\n🚀 Sync Entra ID automatique...");
|
||||
await syncEntraIdUsers();
|
||||
}, 5000);
|
||||
});
|
||||
10578
GTA_P_V2/project/public/Backend/server.js
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
38
GTA_P_V2/project/public/Backend/useSSENotifications.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// hooks/useSSENotifications.js
|
||||
import { useEffect, useCallback } from 'react';
|
||||
|
||||
export const useSSENotifications = (token, collaborateurId, onEventReceived) => {
|
||||
useEffect(() => {
|
||||
if (!token || !collaborateurId) return;
|
||||
|
||||
const eventSource = new EventSource(
|
||||
`/api/events?token=${encodeURIComponent(token)}`
|
||||
);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
console.log('📨 SSE reçu:', data);
|
||||
|
||||
// Log spécifique pour les récupérations
|
||||
if (data.type === 'demande-validated' && data.typeConge === 'Récupération') {
|
||||
console.log('🎨 Couleur reçue:', data.couleurHex);
|
||||
}
|
||||
|
||||
onEventReceived(data);
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur parsing SSE:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('❌ Erreur SSE:', error);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [token, collaborateurId, onEventReceived]);
|
||||
};
|
||||
19
GTA_P_V2/project/public/Backend/webhook-config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// webhook-config.js
|
||||
|
||||
|
||||
|
||||
export const WEBHOOKS= {
|
||||
COLLABORATEURS_URL: process.env.COLLABORATEURS_URL || 'http://localhost:3000',
|
||||
RH_URL: process.env.RH_URL || 'http://localhost:3001',
|
||||
SECRET_KEY: process.env.WEBHOOK_SECRET || 'secret-key-securise'
|
||||
};
|
||||
|
||||
// Types d'événements
|
||||
export const EVENTS= {
|
||||
DEMANDE_VALIDATED: 'demande.validated',
|
||||
DEMANDE_CREATED: 'demande.created',
|
||||
DEMANDE_UPDATED: 'demande.updated',
|
||||
DEMANDE_DELETED: 'demande.deleted',
|
||||
COMPTEUR_UPDATED: 'compteur.updated'
|
||||
|
||||
};
|
||||
116
GTA_P_V2/project/public/Backend/webhook-utils.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// webhook-utils.js (VERSION ES MODULES - CORRIGÉE)
|
||||
// Pour projets avec "type": "module" dans package.json
|
||||
|
||||
import axios from 'axios';
|
||||
import crypto from 'crypto';
|
||||
|
||||
class WebhookManager {
|
||||
constructor(secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère une signature HMAC SHA-256 pour sécuriser le webhook
|
||||
* @param {Object} payload - Les données à signer
|
||||
* @returns {string} La signature hexadécimale
|
||||
*/
|
||||
generateSignature(payload) {
|
||||
return crypto
|
||||
.createHmac('sha256', this.secretKey)
|
||||
.update(JSON.stringify(payload))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie la signature d'un webhook reçu
|
||||
* @param {Object} payload - Les données reçues
|
||||
* @param {string} receivedSignature - La signature reçue dans le header
|
||||
* @returns {boolean} True si la signature est valide
|
||||
*/
|
||||
verifySignature(payload, receivedSignature) {
|
||||
if (!receivedSignature) {
|
||||
console.error('❌ Aucune signature fournie');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const expectedSignature = this.generateSignature(payload);
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(expectedSignature),
|
||||
Buffer.from(receivedSignature)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur vérification signature:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un webhook à une URL cible avec retry automatique
|
||||
* @param {string} targetUrl - URL du serveur cible
|
||||
* @param {string} eventType - Type d'événement (ex: 'demande.validated')
|
||||
* @param {Object} data - Données de l'événement
|
||||
* @param {number} retries - Nombre de tentatives (défaut: 3)
|
||||
* @returns {Promise<Object>} La réponse du serveur
|
||||
*/
|
||||
async sendWebhook(targetUrl, eventType, data, retries = 3) {
|
||||
const payload = {
|
||||
event: eventType,
|
||||
data: data,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const signature = this.generateSignature(payload);
|
||||
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
console.log(`📤 Envoi webhook: ${eventType} vers ${targetUrl} (tentative ${attempt}/${retries})`);
|
||||
console.log(` Données:`, JSON.stringify(data, null, 2));
|
||||
|
||||
const response = await axios.post(
|
||||
`${targetUrl}/api/webhook/receive`,
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Webhook-Signature': signature
|
||||
},
|
||||
timeout: 5000 // 5 secondes de timeout
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`✅ Webhook envoyé avec succès: ${eventType}`);
|
||||
return response.data;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Erreur envoi webhook (tentative ${attempt}/${retries}):`, error.message);
|
||||
|
||||
if (attempt === retries) {
|
||||
console.error(`❌ Échec définitif du webhook après ${retries} tentatives`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Attendre avant de réessayer (backoff exponentiel)
|
||||
const waitTime = 1000 * attempt;
|
||||
console.log(`⏳ Nouvelle tentative dans ${waitTime}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un webhook sans attendre la réponse (fire and forget)
|
||||
* Utile pour ne pas bloquer l'exécution
|
||||
* @param {string} targetUrl - URL du serveur cible
|
||||
* @param {string} eventType - Type d'événement
|
||||
* @param {Object} data - Données de l'événement
|
||||
*/
|
||||
sendWebhookAsync(targetUrl, eventType, data) {
|
||||
this.sendWebhook(targetUrl, eventType, data)
|
||||
.catch(error => {
|
||||
console.error('❌ Webhook async échoué (non bloquant):', error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default WebhookManager;
|
||||
BIN
GTA_P_V2/project/public/assets/GA.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
1
GTA_P_V2/project/public/assets/GA.svg
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
1
GTA_P_V2/project/public/assets/GATitre.svg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
GTA_P_V2/project/public/assets/ImageEnsup.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
GTA_P_V2/project/public/assets/Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
GTA_P_V2/project/public/assets/Logo_Ensitech.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
GTA_P_V2/project/public/assets/utilisateur.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
114
GTA_P_V2/project/src/App.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'; // ⭐ Ajout de useAuth
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Login from './pages/Login';
|
||||
import Requests from './pages/Requests';
|
||||
import Calendar from './pages/Calendar';
|
||||
import Manager from './pages/Manager';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import EmployeeDetails from './pages/EmployeeDetails';
|
||||
import Collaborateur from './pages/Collaborateur';
|
||||
import CompteRenduActivites from './pages/CompteRenduActivite';
|
||||
import GlobalTutorial from './components/GlobalTutorial';
|
||||
|
||||
// ⭐ Créer un composant séparé pour utiliser useAuth
|
||||
function AppContent() {
|
||||
const { user } = useAuth();
|
||||
const userId = user?.id || user?.CollaborateurADId || user?.ID;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ⭐ Tutoriel global - Il s'affichera sur toutes les pages */}
|
||||
<GlobalTutorial userId={userId} />
|
||||
|
||||
<Routes>
|
||||
{/* Route publique */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* Routes protégées */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/demandes"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['Validateur', 'Validatrice', 'Collaborateur', 'Collaboratrice', 'Apprenti', 'RH', 'Admin', 'Directeur de campus', 'Directrice de campus']}>
|
||||
<Requests />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/calendrier"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti', 'Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
|
||||
<Calendar />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/manager"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
|
||||
<Manager />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/collaborateur"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti']}>
|
||||
<Collaborateur />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/employee/:id"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['RH', 'Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'Admin', 'President']}>
|
||||
<EmployeeDetails />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ⭐ Nouvelle route pour Compte-Rendu d'Activités */}
|
||||
<Route
|
||||
path="/compte-rendu-activites"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
|
||||
<CompteRenduActivites />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Redirection par défaut */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
{/* Route 404 - Redirection vers dashboard */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<AppContent />
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
62
GTA_P_V2/project/src/authConfig.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// authConfig.js
|
||||
const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
|
||||
// Détection environnements
|
||||
const isProduction = hostname === "mygta.ensup-adm.net";
|
||||
|
||||
// ✅ EXPORT : API URL
|
||||
export const API_BASE_URL = "/api";
|
||||
|
||||
// ✅ EXPORT : MSAL Config - OPTIMISÉ POUR MOBILE iOS
|
||||
export const msalConfig = {
|
||||
auth: {
|
||||
clientId: "4bb4cc24-bac3-427c-b02c-5d14fc67b561",
|
||||
authority: "https://login.microsoftonline.com/9840a2a0-6ae1-4688-b03d-d2ec291be0f9",
|
||||
redirectUri: isProduction
|
||||
? "https://mygta.ensup-adm.net"
|
||||
: `${protocol}//${hostname}`,
|
||||
navigateToLoginRequestUrl: false, // ✅ false pour éviter double redirection
|
||||
postLogoutRedirectUri: isProduction
|
||||
? "https://mygta.ensup-adm.net"
|
||||
: `${protocol}//${hostname}`,
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: "localStorage",
|
||||
storeAuthStateInCookie: true,
|
||||
},
|
||||
system: {
|
||||
allowRedirectInIframe: false,
|
||||
allowNativeBroker: false,
|
||||
loggerOptions: {
|
||||
logLevel: "Verbose",
|
||||
piiLoggingEnabled: false,
|
||||
},
|
||||
windowHashTimeout: 25000,
|
||||
iframeHashTimeout: 25000,
|
||||
loadFrameTimeout: 25000,
|
||||
tokenRenewalOffsetSeconds: 300,
|
||||
asyncPopups: false,
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ EXPORT : Permissions Graph
|
||||
export const loginRequest = {
|
||||
scopes: [
|
||||
"User.Read",
|
||||
"User.Read.All",
|
||||
"Group.Read.All",
|
||||
"GroupMember.Read.All",
|
||||
"Mail.Send",
|
||||
],
|
||||
prompt: "select_account",
|
||||
responseMode: "fragment",
|
||||
};
|
||||
|
||||
// ✅ Log de configuration au démarrage
|
||||
console.log("🔧 Config Auth:", {
|
||||
hostname,
|
||||
protocol,
|
||||
API_BASE_URL,
|
||||
redirectUri: msalConfig.auth.redirectUri
|
||||
});
|
||||
788
GTA_P_V2/project/src/components/EditLeaveRequestModal.jsx
Normal file
@@ -0,0 +1,788 @@
|
||||
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;
|
||||
728
GTA_P_V2/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 mai de l\'année suivante !',
|
||||
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 au JPO/SF.',
|
||||
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;
|
||||
133
GTA_P_V2/project/src/components/MedicalDocuments.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FileText, Download, Eye, Loader } from 'lucide-react';
|
||||
|
||||
const MedicalDocuments = ({ demandeId }) => {
|
||||
const [documents, setDocuments] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDocuments = async () => {
|
||||
if (!demandeId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/medical-documents/${demandeId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setDocuments(data.documents || []);
|
||||
} else {
|
||||
setError(data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur récupération documents:', err);
|
||||
setError('Impossible de charger les documents');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDocuments();
|
||||
}, [demandeId]);
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const getFileIcon = (type) => {
|
||||
if (type === 'application/pdf') {
|
||||
return (
|
||||
<svg className="w-5 h-5 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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-5 h-5 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>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader className="w-5 h-5 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-sm text-gray-500">Chargement...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-sm text-red-600 py-2">
|
||||
Erreur : {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (documents.length === 0) {
|
||||
return null; // Ne rien afficher s'il n'y a pas de documents
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-gray-500 mb-2">
|
||||
Justificatifs médicaux ({documents.length})
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex-shrink-0">
|
||||
{getFileIcon(doc.type)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{doc.nom}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span>{formatFileSize(doc.taille)}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(doc.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MedicalDocuments;
|
||||
825
GTA_P_V2/project/src/components/NewLeaveRequestModal.jsx
Normal file
@@ -0,0 +1,825 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, AlertCircle } from 'lucide-react';
|
||||
|
||||
const NewLeaveRequestModal = ({
|
||||
onClose,
|
||||
availableLeaveCounters,
|
||||
userId,
|
||||
userEmail,
|
||||
userName,
|
||||
onRequestSubmitted,
|
||||
preselectedStartDate = null,
|
||||
preselectedEndDate = null,
|
||||
preselectedType = null,
|
||||
userRole,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
types: preselectedType ? [preselectedType] : [],
|
||||
startDate: preselectedStartDate || '',
|
||||
endDate: preselectedEndDate || '',
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const [repartition, setRepartition] = useState({});
|
||||
const [periodeSelection, setPeriodeSelection] = useState({});
|
||||
const [totalDays, setTotalDays] = useState(0);
|
||||
const [saturdayCount, setSaturdayCount] = useState(0);
|
||||
const [holidayCount, setHolidayCount] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [countersData, setCountersData] = useState(null);
|
||||
const [isLoadingCounters, setIsLoadingCounters] = useState(true);
|
||||
|
||||
// ⭐ État pour stocker les jours fériés
|
||||
const [publicHolidays, setPublicHolidays] = useState({});
|
||||
|
||||
// ⭐ Fonction pour vérifier si une date est un weekend
|
||||
const isWeekend = (dateString) => {
|
||||
if (!dateString) return false;
|
||||
const date = new Date(dateString);
|
||||
const day = date.getDay();
|
||||
return day === 0 || day === 6; // 0 = Dimanche, 6 = Samedi
|
||||
};
|
||||
|
||||
// ⭐ Fonction pour obtenir le prochain jour ouvrable
|
||||
const getNextWorkingDay = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
const day = date.getDay();
|
||||
|
||||
// Si c'est vendredi (5), ajouter 3 jours pour arriver à lundi
|
||||
if (day === 5) {
|
||||
date.setDate(date.getDate() + 3);
|
||||
}
|
||||
// Si c'est samedi (6), ajouter 2 jours
|
||||
else if (day === 6) {
|
||||
date.setDate(date.getDate() + 2);
|
||||
}
|
||||
// Si c'est dimanche (0), ajouter 1 jour
|
||||
else if (day === 0) {
|
||||
date.setDate(date.getDate() + 1);
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// ⭐ Charger les jours fériés depuis l'API gouvernementale
|
||||
useEffect(() => {
|
||||
const fetchPublicHolidays = async () => {
|
||||
try {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const nextYear = currentYear + 1;
|
||||
|
||||
const [currentYearData, nextYearData] = await Promise.all([
|
||||
fetch(`https://calendrier.api.gouv.fr/jours-feries/metropole/${currentYear}.json`).then(r => r.json()),
|
||||
fetch(`https://calendrier.api.gouv.fr/jours-feries/metropole/${nextYear}.json`).then(r => r.json())
|
||||
]);
|
||||
|
||||
const allHolidays = { ...currentYearData, ...nextYearData };
|
||||
setPublicHolidays(allHolidays);
|
||||
|
||||
console.log('📅 Jours fériés chargés:', allHolidays);
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur chargement jours fériés:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPublicHolidays();
|
||||
}, []);
|
||||
|
||||
// ⭐ Fonction pour vérifier si une date est un jour férié
|
||||
const isPublicHoliday = (date) => {
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return dateStr in publicHolidays;
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
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 => ({
|
||||
...prev,
|
||||
startDate: preselectedStartDate || prev.startDate,
|
||||
endDate: preselectedEndDate || prev.endDate,
|
||||
types: preselectedType ? [preselectedType] : prev.types
|
||||
}));
|
||||
}
|
||||
}, [preselectedStartDate, preselectedEndDate, preselectedType]);
|
||||
|
||||
// ⭐ Calcul des jours ouvrés en excluant les jours fériés
|
||||
useEffect(() => {
|
||||
if (formData.startDate && formData.endDate) {
|
||||
const start = new Date(formData.startDate);
|
||||
const end = new Date(formData.endDate);
|
||||
let workingDays = 0;
|
||||
let saturdays = 0;
|
||||
let holidays = 0;
|
||||
|
||||
const current = new Date(start);
|
||||
while (current <= end) {
|
||||
const dayOfWeek = current.getDay();
|
||||
const isHoliday = isPublicHoliday(current);
|
||||
|
||||
if (isHoliday) {
|
||||
holidays++;
|
||||
console.log(`🎉 Jour férié détecté: ${current.toISOString().split('T')[0]} - ${publicHolidays[current.toISOString().split('T')[0]]}`);
|
||||
} else if (dayOfWeek === 6) {
|
||||
saturdays++;
|
||||
} else if (dayOfWeek !== 0) {
|
||||
workingDays++;
|
||||
}
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
setTotalDays(workingDays);
|
||||
setSaturdayCount(saturdays);
|
||||
setHolidayCount(holidays);
|
||||
|
||||
console.log('📊 Calcul période:', {
|
||||
debut: formData.startDate,
|
||||
fin: formData.endDate,
|
||||
joursOuvres: workingDays,
|
||||
samedis: saturdays,
|
||||
joursFeries: holidays
|
||||
});
|
||||
}
|
||||
}, [formData.startDate, formData.endDate, publicHolidays]);
|
||||
|
||||
const getMinDate = () => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return tomorrow.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// ⭐ NOUVELLE FONCTION : Date minimum pour Formation (1 semaine = 7 jours)
|
||||
const getMinDateFormation = () => {
|
||||
const nextWeek = new Date();
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
return nextWeek.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.types.length > 0) {
|
||||
const newRepartition = {};
|
||||
const newPeriodes = {};
|
||||
|
||||
formData.types.forEach(t => {
|
||||
newRepartition[t] = repartition[t] || 0;
|
||||
newPeriodes[t] = periodeSelection[t] || 'Journée entière';
|
||||
});
|
||||
|
||||
setRepartition(newRepartition);
|
||||
setPeriodeSelection(newPeriodes);
|
||||
}
|
||||
}, [formData.types]);
|
||||
|
||||
const handleRepartitionChange = (type, value) => {
|
||||
const val = Math.max(0, parseFloat(value) || 0);
|
||||
setRepartition(prev => ({ ...prev, [type]: val }));
|
||||
};
|
||||
|
||||
const handlePeriodeChange = (type, periode) => {
|
||||
setPeriodeSelection(prev => ({ ...prev, [type]: periode }));
|
||||
|
||||
if (formData.types.length === 1) {
|
||||
if (periode === 'Matin' || periode === 'Après-midi') {
|
||||
setRepartition(prev => ({ ...prev, [type]: 0.5 }));
|
||||
} else {
|
||||
setRepartition(prev => ({ ...prev, [type]: totalDays }));
|
||||
}
|
||||
} else {
|
||||
if (formData.startDate === formData.endDate && (periode === 'Matin' || periode === 'Après-midi')) {
|
||||
const currentRepartition = repartition[type] || 0;
|
||||
if (currentRepartition === 0 || currentRepartition === 1) {
|
||||
setRepartition(prev => ({ ...prev, [type]: 0.5 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
console.log('\n🔍 === VALIDATION FORMULAIRE ===');
|
||||
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);
|
||||
|
||||
if (formData.types.length === 0) {
|
||||
setError('Veuillez sélectionner au moins un type de congé');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.startDate || !formData.endDate) {
|
||||
setError('Veuillez sélectionner les dates');
|
||||
return false;
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const start = new Date(formData.startDate);
|
||||
const end = new Date(formData.endDate);
|
||||
|
||||
// ⭐ VALIDATION SPÉCIALE POUR FORMATION (7 JOURS D'AVANCE)
|
||||
if (formData.types.includes('Formation')) {
|
||||
const minFormationDate = new Date(today);
|
||||
minFormationDate.setDate(minFormationDate.getDate() + 7);
|
||||
|
||||
if (start < minFormationDate) {
|
||||
setError('La formation doit être posée au moins 7 jours à l\'avance (1 semaine minimum).');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (start.getTime() === today.getTime() || end.getTime() === today.getTime()) {
|
||||
setError("Vous ne pouvez pas poser un congé pour aujourd'hui.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (start < today) {
|
||||
setError("La date de début ne peut pas être antérieure à aujourd'hui.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (end < start) {
|
||||
setError('La date de fin doit être après la date de début.');
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
console.log('📊 Jours demandés:', joursDemandesParType);
|
||||
console.log('📊 Soldes disponibles:', safeCounters);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Validation complète OK');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
|
||||
console.log('\n🚀 === SOUMISSION DEMANDE ===');
|
||||
|
||||
if (!validateForm()) {
|
||||
console.log('❌ Validation échouée');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const formDataToSend = new FormData();
|
||||
|
||||
formDataToSend.append('DateDebut', formData.startDate);
|
||||
formDataToSend.append('DateFin', formData.endDate);
|
||||
formDataToSend.append('Email', userEmail);
|
||||
formDataToSend.append('Nom', userName);
|
||||
formDataToSend.append('Commentaire', formData.reason || '');
|
||||
|
||||
let totalJoursToSend = totalDays;
|
||||
|
||||
if (formData.types.length === 1 && formData.startDate === formData.endDate) {
|
||||
const type = formData.types[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);
|
||||
|
||||
const repartitionArray = formData.types.map(type => {
|
||||
let nombreJours;
|
||||
let periodeJournee = 'Journée entière';
|
||||
|
||||
if (formData.types.length === 1) {
|
||||
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 {
|
||||
nombreJours = repartition[type] || 0;
|
||||
periodeJournee = periodeSelection[type] || 'Journée entière';
|
||||
}
|
||||
|
||||
console.log(`📝 ${type}: ${nombreJours}j (${periodeJournee})`);
|
||||
|
||||
return {
|
||||
TypeConge: type,
|
||||
NombreJours: nombreJours,
|
||||
PeriodeJournee: ['CP', 'RTT', 'Récup'].includes(type) ? periodeJournee : 'Journée entière'
|
||||
};
|
||||
});
|
||||
|
||||
console.log('📤 Répartition envoyée:', repartitionArray);
|
||||
console.log(`📅 NombreJours total: ${totalJoursToSend}`);
|
||||
|
||||
formDataToSend.append('Repartition', JSON.stringify(repartitionArray));
|
||||
|
||||
const response = await fetch('/api/submitLeaveRequest', {
|
||||
method: 'POST',
|
||||
body: formDataToSend
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
console.log('✅ Demande soumise avec succès');
|
||||
onClose();
|
||||
if (onRequestSubmitted) onRequestSubmitted(result);
|
||||
} else {
|
||||
console.error('❌ Erreur serveur:', result.message);
|
||||
setError(result.message || 'Erreur lors de la soumission');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Erreur réseau:', err);
|
||||
setError(err.message || 'Une erreur est survenue');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeToggle = (type) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
types: prev.types.includes(type)
|
||||
? prev.types.filter(t => t !== type)
|
||||
: [...prev.types, type]
|
||||
}));
|
||||
};
|
||||
|
||||
// ⭐ Ajout de Formation en bleu pour les apprentis
|
||||
const availableTypes = userRole === 'Apprenti'
|
||||
? [
|
||||
{
|
||||
key: 'CP',
|
||||
label: 'Congé(s) payé(s)',
|
||||
available: countersData?.data?.cpN?.solde || 0,
|
||||
details: countersData?.data?.cpN
|
||||
},
|
||||
{ key: 'Formation', label: 'Formation' },
|
||||
{
|
||||
key: 'Récup',
|
||||
label: 'Récupération(s)',
|
||||
available: countersData?.data?.recupN?.solde || 0
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: 'CP',
|
||||
label: 'Congé(s) payé(s)',
|
||||
available: countersData?.data?.cpN?.solde || 0,
|
||||
details: countersData?.data?.cpN
|
||||
},
|
||||
{
|
||||
key: 'RTT',
|
||||
label: 'RTT',
|
||||
available: countersData?.data?.rttN?.solde || 0,
|
||||
details: countersData?.data?.rttN
|
||||
},
|
||||
{
|
||||
key: 'Récup',
|
||||
label: 'Récupération',
|
||||
available: countersData?.data?.recupN?.solde || 0
|
||||
}
|
||||
];
|
||||
|
||||
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">
|
||||
<div className="flex justify-between items-center p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Nouvelle demande</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-5">
|
||||
{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 d'absences *
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{availableTypes.map(type => {
|
||||
return (
|
||||
<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' :
|
||||
type.key === 'RTT' ? 'bg-green-100 text-green-800' :
|
||||
type.key === 'Récup' ? 'bg-orange-100 text-orange-800' :
|
||||
type.key === 'Formation' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{type.label}
|
||||
</span>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* ⭐ AVERTISSEMENT FORMATION */}
|
||||
{formData.types.includes('Formation') && (
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-blue-700 text-xs">
|
||||
⚠️ La formation doit être posée au moins <strong>7 jours à l'avance</strong> (1 semaine minimum).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||
Date début *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.startDate}
|
||||
min={formData.types.includes('Formation') ? getMinDateFormation() : getMinDate()}
|
||||
onChange={(e) => {
|
||||
const newStartDate = e.target.value;
|
||||
setFormData(prev => {
|
||||
// Si c'est un weekend, ajuster automatiquement
|
||||
const adjustedDate = isWeekend(newStartDate)
|
||||
? getNextWorkingDay(newStartDate)
|
||||
: newStartDate;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
startDate: adjustedDate,
|
||||
// Mettre à jour la date de fin si elle est vide ou antérieure
|
||||
endDate: !prev.endDate || new Date(prev.endDate) < new Date(adjustedDate)
|
||||
? adjustedDate
|
||||
: prev.endDate
|
||||
};
|
||||
});
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{formData.startDate && isWeekend(formData.startDate) && (
|
||||
<p className="text-xs text-orange-600 mt-1">
|
||||
⚠️ Les weekends ne sont pas comptabilisés
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||
Date fin *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.endDate}
|
||||
min={formData.startDate || (formData.types.includes('Formation') ? getMinDateFormation() : getMinDate())}
|
||||
onChange={(e) => {
|
||||
const newEndDate = e.target.value;
|
||||
// Si c'est un weekend, ajuster automatiquement
|
||||
const adjustedDate = isWeekend(newEndDate)
|
||||
? getNextWorkingDay(newEndDate)
|
||||
: newEndDate;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
endDate: adjustedDate
|
||||
}));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{formData.endDate && isWeekend(formData.endDate) && (
|
||||
<p className="text-xs text-orange-600 mt-1">
|
||||
⚠️ Les weekends ne sont pas comptabilisés
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ⭐ Affichage du récapitulatif avec jours fériés */}
|
||||
{totalDays > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-900">
|
||||
Jours ouvrés : <span className="text-lg font-bold">{totalDays}</span>
|
||||
</p>
|
||||
{holidayCount > 0 && (
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
🎉 {holidayCount} jour{holidayCount > 1 ? 's' : ''} férié{holidayCount > 1 ? 's' : ''} exclu{holidayCount > 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
{saturdayCount > 0 && (
|
||||
<p className="text-xs text-blue-700">
|
||||
📅 {saturdayCount} samedi{saturdayCount > 1 ? 's' : ''} exclu{saturdayCount > 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{['Matin', 'Après-midi', 'Journée entière'].map((periode) => (
|
||||
<button
|
||||
key={periode}
|
||||
type="button"
|
||||
onClick={() => handlePeriodeChange(formData.types[0], periode)}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${(periodeSelection[formData.types[0]] || 'Journée entière') === periode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{periode === 'Matin' && '🌅 Matin'}
|
||||
{periode === 'Après-midi' && '☀️ Après-midi'}
|
||||
{periode === 'Journée entière' && '🌞 Journée entière'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<span className="text-lg font-bold text-blue-700">
|
||||
{(() => {
|
||||
const type = formData.types[0];
|
||||
const periode = periodeSelection[type] || 'Journée entière';
|
||||
|
||||
if (formData.startDate === formData.endDate) {
|
||||
return (periode === 'Matin' || periode === 'Après-midi') ? '0.5 jour' : '1 jour';
|
||||
} else {
|
||||
return (periode === 'Matin' || periode === 'Après-midi')
|
||||
? `${(totalDays - 0.5).toFixed(1)} jours`
|
||||
: `${totalDays} jour${totalDays > 1 ? 's' : ''}`;
|
||||
}
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
Indiquez la répartition souhaitée
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{formData.types.map((type) => {
|
||||
const showPeriode = ['CP', 'RTT', 'Récup'].includes(type);
|
||||
const currentValue = repartition[type] || 0;
|
||||
|
||||
return (
|
||||
<div key={type} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">
|
||||
{availableTypes.find(t => t.key === type)?.label || type}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showPeriode && (
|
||||
<div className="mt-2">
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
Période de la journée :
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePeriodeChange(type, 'Matin')}
|
||||
className={`flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors ${periodeSelection[type] === 'Matin'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
🌅 Matin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePeriodeChange(type, 'Après-midi')}
|
||||
className={`flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors ${periodeSelection[type] === 'Après-midi'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
☀️ Après-midi
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePeriodeChange(type, 'Journée entière')}
|
||||
className={`flex-1 px-3 py-1.5 rounded text-xs font-medium transition-colors ${periodeSelection[type] === 'Journée entière'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
🌞 Journée
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-center">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
Durée :
|
||||
</span>
|
||||
<span className="ml-1 text-sm font-bold text-blue-700">
|
||||
{currentValue} jour{currentValue > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-900 mb-2">
|
||||
Motif (optionnel)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.reason}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, reason: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
placeholder="Précisez le motif..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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 whitespace-pre-line">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 border border-gray-300 rounded-lg hover:bg-gray-50 font-medium transition-colors text-gray-700"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
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'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewLeaveRequestModal;
|
||||
35
GTA_P_V2/project/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
const ProtectedRoute = ({ children, allowedRoles = [] }) => {
|
||||
const { isAuthorized, user, isLoading } = useAuth();
|
||||
|
||||
// ✅ FIX MOBILE : Attendre la fin du chargement avant de rediriger
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-cyan-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 font-medium">Chargement en cours...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Vérifier si l'utilisateur est autorisé
|
||||
if (!isAuthorized || !user) {
|
||||
console.log('❌ ProtectedRoute: Utilisateur non autorisé, redirection vers /login');
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// ✅ Vérifier les rôles autorisés si spécifiés
|
||||
if (allowedRoles.length > 0 && !allowedRoles.includes(user.role)) {
|
||||
console.log(`❌ ProtectedRoute: Rôle ${user.role} non autorisé pour cette route`);
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
217
GTA_P_V2/project/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { LogOut, Calendar, Home, FileText, X, Users, Clock } 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;
|
||||
|
||||
const getRoleBadgeClass = (role) => {
|
||||
switch (role) {
|
||||
case 'Admin':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'Validateur':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'Validatrice':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'Directeur de campus':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'Directrice de campus':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'President':
|
||||
return 'bg-indigo-100 text-indigo-800';
|
||||
case 'Collaborateur':
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ VERSION ULTRA-ROBUSTE pour isForfaitJour
|
||||
const isForfaitJour = (() => {
|
||||
if (!user?.TypeContrat && !user?.typeContrat) return false;
|
||||
|
||||
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 (
|
||||
<>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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'}
|
||||
`}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<img
|
||||
src="/assets/GA.svg"
|
||||
alt="GTA Logo"
|
||||
className="h-24 w-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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?.service || "Service non défini"}
|
||||
</p>
|
||||
{user?.role && (
|
||||
<span
|
||||
className={`inline-block mt-2 px-3 py-1 text-xs font-medium rounded-full ${getRoleBadgeClass(
|
||||
user.role
|
||||
)}`}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-5 h-5" />
|
||||
<span className="font-medium">Demandes</span>
|
||||
</Link>
|
||||
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="w-5 h-5" />
|
||||
<span className="font-medium">Calendrier</span>
|
||||
</Link>
|
||||
|
||||
{/* ✅ 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"
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-5 h-5" />
|
||||
<span className="font-medium">CRA</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>
|
||||
|
||||
<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;
|
||||
395
GTA_P_V2/project/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,395 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { useMsal } from '@azure/msal-react';
|
||||
import { loginRequest, API_BASE_URL } from '../authConfig';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) throw new Error('useAuth must be used within an AuthProvider');
|
||||
return context;
|
||||
};
|
||||
|
||||
// ✅ Détection mobile améliorée
|
||||
const isMobileDevice = () => {
|
||||
const ua = navigator.userAgent;
|
||||
return /iPhone|iPad|iPod|Android|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua);
|
||||
};
|
||||
|
||||
const shouldUseRedirect = () => {
|
||||
if (isMobileDevice()) {
|
||||
return true;
|
||||
}
|
||||
return window.innerWidth < 768;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const { instance, accounts, inProgress } = useMsal();
|
||||
const [user, setUser] = useState(null);
|
||||
const [userGroups, setUserGroups] = useState([]);
|
||||
const [isAuthorized, setIsAuthorized] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const getApiUrl = (endpoint) => {
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
|
||||
return `${API_BASE_URL}/${cleanEndpoint}`;
|
||||
};
|
||||
|
||||
// --- Vérifie l'autorisation de l'utilisateur via groupes
|
||||
const checkUserAuthorization = async (userPrincipalName, accessToken) => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl('check-user-groups'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify({ userPrincipalName })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUserGroups(data.groups || []);
|
||||
setIsAuthorized(data.authorized || false);
|
||||
return data;
|
||||
}
|
||||
return { authorized: false, groups: [] };
|
||||
} catch (error) {
|
||||
console.error('Erreur vérification groupes:', error);
|
||||
return { authorized: false, groups: [] };
|
||||
}
|
||||
};
|
||||
|
||||
// --- Synchronisation utilisateur connecté
|
||||
const syncUserToDatabase = async (entraUser, accessToken) => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl('initial-sync'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify(entraUser)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('✅ Utilisateur synchronisé:', entraUser.userPrincipalName);
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur synchronisation utilisateur:', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// --- Full sync admin
|
||||
const fullSyncDatabase = async (accessToken) => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl('initial-sync'), {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('✅ Full sync terminée:', data);
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur full sync:', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// --- Gestion login réussi
|
||||
const handleSuccessfulAuth = async (authResponse) => {
|
||||
try {
|
||||
console.log('🔐 Traitement authentification réussie...');
|
||||
const account = authResponse.account;
|
||||
const accessToken = authResponse.accessToken;
|
||||
|
||||
if (!account || !accessToken) {
|
||||
throw new Error('Données d\'authentification incomplètes');
|
||||
}
|
||||
|
||||
let entraUser = {
|
||||
id: account.homeAccountId,
|
||||
displayName: account.name,
|
||||
userPrincipalName: account.username,
|
||||
mail: account.username
|
||||
};
|
||||
|
||||
// Appel Graph API pour enrichir les données
|
||||
console.log('📞 Appel Graph API...');
|
||||
try {
|
||||
const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||||
});
|
||||
|
||||
if (graphResponse.ok) {
|
||||
const graphData = await graphResponse.json();
|
||||
entraUser = { ...entraUser, ...graphData };
|
||||
console.log('✅ Données Graph récupérées');
|
||||
}
|
||||
} catch (graphError) {
|
||||
console.warn('⚠️ Erreur Graph API:', graphError);
|
||||
}
|
||||
|
||||
// Synchronisation utilisateur
|
||||
console.log('🔄 Synchronisation utilisateur...');
|
||||
const syncResult = await syncUserToDatabase(entraUser, accessToken);
|
||||
|
||||
if (syncResult?.role === 'Admin') {
|
||||
console.log('👑 Admin détecté → lancement full sync...');
|
||||
await fullSyncDatabase(accessToken);
|
||||
}
|
||||
|
||||
// Vérification des groupes
|
||||
console.log('🔍 Vérification groupes...');
|
||||
const authResult = await checkUserAuthorization(entraUser.userPrincipalName, accessToken);
|
||||
|
||||
if (authResult.authorized) {
|
||||
console.log('✅ Utilisateur autorisé');
|
||||
|
||||
const userData = {
|
||||
id: syncResult?.localUserId || entraUser.id,
|
||||
CollaborateurADId: syncResult?.localUserId,
|
||||
entraUserId: entraUser.id,
|
||||
name: entraUser.displayName,
|
||||
prenom: entraUser.givenName || entraUser.displayName?.split(' ')[0] || '',
|
||||
nom: entraUser.surname || entraUser.displayName?.split(' ')[1] || '',
|
||||
email: entraUser.mail || entraUser.userPrincipalName,
|
||||
userPrincipalName: entraUser.userPrincipalName,
|
||||
role: syncResult?.role || 'Employe',
|
||||
service: syncResult?.service || entraUser.department || 'Non défini',
|
||||
jobTitle: entraUser.jobTitle,
|
||||
department: entraUser.department,
|
||||
officeLocation: entraUser.officeLocation,
|
||||
typeContrat: syncResult?.typeContrat || '37h',
|
||||
dateEntree: syncResult?.dateEntree || null,
|
||||
groups: authResult.groups
|
||||
};
|
||||
|
||||
setUser(userData);
|
||||
setIsAuthorized(true);
|
||||
console.log('✅ Connexion réussie:', userData.email);
|
||||
|
||||
} else {
|
||||
console.error('❌ Utilisateur non autorisé');
|
||||
throw new Error('Utilisateur non autorisé - pas membre des groupes requis');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur handleSuccessfulAuth:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ SIMPLIFIÉ : L'initialisation MSAL est déjà faite dans main.jsx
|
||||
useEffect(() => {
|
||||
const processAuthentication = async () => {
|
||||
// Attendre que MSAL finisse ses opérations en cours
|
||||
if (inProgress !== 'none') {
|
||||
console.log('⏳ MSAL inProgress:', inProgress);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🌐 AuthContext - Vérification session');
|
||||
console.log('📊 Comptes MSAL:', accounts.length);
|
||||
|
||||
// Si un compte existe, récupérer le token et traiter l'auth
|
||||
if (accounts.length > 0) {
|
||||
const account = accounts[0];
|
||||
console.log('✅ Compte trouvé:', account.username);
|
||||
|
||||
try {
|
||||
// Définir le compte actif
|
||||
instance.setActiveAccount(account);
|
||||
|
||||
// Acquérir un token silencieusement
|
||||
const tokenResponse = await instance.acquireTokenSilent({
|
||||
...loginRequest,
|
||||
account: account
|
||||
});
|
||||
|
||||
console.log('✅ Token acquis silencieusement');
|
||||
await handleSuccessfulAuth(tokenResponse);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur acquireTokenSilent:', error);
|
||||
|
||||
// Si interaction requise, relancer l'auth
|
||||
if (error.name === 'InteractionRequiredAuthError' ||
|
||||
error.errorCode === 'consent_required' ||
|
||||
error.errorCode === 'interaction_required' ||
|
||||
error.errorCode === 'login_required') {
|
||||
|
||||
console.log('🔄 Interaction requise, relancement...');
|
||||
try {
|
||||
if (shouldUseRedirect()) {
|
||||
await instance.acquireTokenRedirect({
|
||||
...loginRequest,
|
||||
account: account
|
||||
});
|
||||
} else {
|
||||
const response = await instance.acquireTokenPopup({
|
||||
...loginRequest,
|
||||
account: account
|
||||
});
|
||||
await handleSuccessfulAuth(response);
|
||||
}
|
||||
} catch (interactionError) {
|
||||
console.error('❌ Erreur interaction:', interactionError);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pas de compte = utilisateur non connecté
|
||||
console.log('ℹ️ Aucun compte MSAL - utilisateur non connecté');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
processAuthentication();
|
||||
}, [instance, accounts, inProgress]);
|
||||
|
||||
// --- Connexion classique
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl('login'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, mot_de_passe: password })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erreur de connexion');
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setUser({
|
||||
id: data.user.id,
|
||||
name: `${data.user.prenom} ${data.user.nom}`,
|
||||
prenom: data.user.prenom,
|
||||
nom: data.user.nom,
|
||||
email: data.user.email,
|
||||
role: data.user.role || 'Employe',
|
||||
service: data.user.service || 'Non défini'
|
||||
});
|
||||
setIsAuthorized(true);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Erreur de connexion:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Connexion Office 365
|
||||
const loginWithO365 = async () => {
|
||||
try {
|
||||
const useRedirect = shouldUseRedirect();
|
||||
console.log(`🔐 Connexion O365: ${useRedirect ? 'REDIRECT' : 'POPUP'}`);
|
||||
|
||||
if (useRedirect) {
|
||||
await instance.loginRedirect(loginRequest);
|
||||
} else {
|
||||
try {
|
||||
const authResponse = await instance.loginPopup(loginRequest);
|
||||
await handleSuccessfulAuth(authResponse);
|
||||
return true;
|
||||
} catch (popupError) {
|
||||
if (popupError.errorCode === 'popup_window_error' ||
|
||||
popupError.errorCode === 'empty_window_error') {
|
||||
console.warn('⚠️ Popup bloqué, fallback redirect');
|
||||
await instance.loginRedirect(loginRequest);
|
||||
} else {
|
||||
throw popupError;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur login O365:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Déconnexion
|
||||
const logout = async () => {
|
||||
try {
|
||||
const useRedirect = shouldUseRedirect();
|
||||
|
||||
if (accounts.length > 0) {
|
||||
if (useRedirect) {
|
||||
await instance.logoutRedirect({
|
||||
account: accounts[0],
|
||||
postLogoutRedirectUri: window.location.origin
|
||||
});
|
||||
} else {
|
||||
await instance.logoutPopup({
|
||||
account: accounts[0],
|
||||
postLogoutRedirectUri: window.location.origin
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur déconnexion:', error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
setUserGroups([]);
|
||||
setIsAuthorized(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Obtenir token API
|
||||
const getAccessToken = async () => {
|
||||
try {
|
||||
if (accounts.length === 0) {
|
||||
throw new Error('Aucun compte connecté');
|
||||
}
|
||||
|
||||
const response = await instance.acquireTokenSilent({
|
||||
...loginRequest,
|
||||
account: accounts[0]
|
||||
});
|
||||
|
||||
return response.accessToken;
|
||||
} catch (error) {
|
||||
console.error('Erreur obtention token:', error);
|
||||
|
||||
// Tenter une interaction si nécessaire
|
||||
if (error.name === 'InteractionRequiredAuthError') {
|
||||
try {
|
||||
const response = await instance.acquireTokenPopup({
|
||||
...loginRequest,
|
||||
account: accounts[0]
|
||||
});
|
||||
return response.accessToken;
|
||||
} catch (popupError) {
|
||||
console.error('Erreur popup token:', popupError);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
userGroups,
|
||||
isAuthorized,
|
||||
login,
|
||||
loginWithO365,
|
||||
logout,
|
||||
isLoading,
|
||||
getAccessToken
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export default AuthContext;
|
||||
3
GTA_P_V2/project/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
123
GTA_P_V2/project/src/main.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
import { MsalProvider } from "@azure/msal-react";
|
||||
import { PublicClientApplication, EventType } from "@azure/msal-browser";
|
||||
import { msalConfig } from "./authConfig";
|
||||
|
||||
// ✅ CRITIQUE : Créer l'instance MSAL
|
||||
const msalInstance = new PublicClientApplication(msalConfig);
|
||||
|
||||
// ✅ CRITIQUE : Fonction d'initialisation asynchrone
|
||||
async function initializeApp() {
|
||||
console.log('🚀 Initialisation de l\'application...');
|
||||
console.log('🔗 Hash actuel:', window.location.hash);
|
||||
console.log('📍 URL complète:', window.location.href);
|
||||
|
||||
// ✅ Sauvegarder le hash OAuth s'il existe (avant que quoi que ce soit ne le supprime)
|
||||
const currentHash = window.location.hash;
|
||||
if (currentHash && currentHash.includes('code=')) {
|
||||
console.log('🚨 Hash OAuth détecté - Sauvegarde...');
|
||||
sessionStorage.setItem('oauth_hash_backup', currentHash);
|
||||
sessionStorage.setItem('oauth_url_backup', window.location.href);
|
||||
sessionStorage.setItem('oauth_capture_time', Date.now().toString());
|
||||
}
|
||||
|
||||
try {
|
||||
// ✅ CRITIQUE : Initialiser MSAL (requis depuis MSAL 3.x)
|
||||
console.log('⏳ Initialisation MSAL...');
|
||||
await msalInstance.initialize();
|
||||
console.log('✅ MSAL initialisé');
|
||||
|
||||
// ✅ CRITIQUE : Traiter la redirection OAuth AVANT le rendu React
|
||||
console.log('⏳ Traitement handleRedirectPromise...');
|
||||
const response = await msalInstance.handleRedirectPromise();
|
||||
|
||||
if (response) {
|
||||
console.log('✅ Réponse OAuth reçue:', {
|
||||
account: response.account?.username,
|
||||
hasAccessToken: !!response.accessToken,
|
||||
scopes: response.scopes
|
||||
});
|
||||
|
||||
// Nettoyer le hash de l'URL après traitement réussi
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
// Nettoyer le backup
|
||||
sessionStorage.removeItem('oauth_hash_backup');
|
||||
sessionStorage.removeItem('oauth_url_backup');
|
||||
sessionStorage.removeItem('oauth_capture_time');
|
||||
} else {
|
||||
console.log('ℹ️ Pas de réponse OAuth (normal si pas de redirection en cours)');
|
||||
|
||||
// Vérifier s'il y avait un code mais pas de réponse (échec silencieux)
|
||||
const backupHash = sessionStorage.getItem('oauth_hash_backup');
|
||||
if (backupHash && backupHash.includes('code=')) {
|
||||
const captureTime = sessionStorage.getItem('oauth_capture_time');
|
||||
const elapsed = Date.now() - parseInt(captureTime || '0');
|
||||
|
||||
// Si le backup a moins de 30 secondes, c'est un échec récent
|
||||
if (elapsed < 30000) {
|
||||
console.warn('⚠️ Code OAuth détecté mais non traité par MSAL');
|
||||
console.log('🔧 Le hash était:', backupHash.substring(0, 100) + '...');
|
||||
}
|
||||
|
||||
// Nettoyer le backup après vérification
|
||||
sessionStorage.removeItem('oauth_hash_backup');
|
||||
sessionStorage.removeItem('oauth_url_backup');
|
||||
sessionStorage.removeItem('oauth_capture_time');
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Configurer les événements MSAL pour le debug
|
||||
msalInstance.addEventCallback((event) => {
|
||||
if (event.eventType === EventType.LOGIN_SUCCESS) {
|
||||
console.log('🎉 LOGIN_SUCCESS event:', event.payload?.account?.username);
|
||||
}
|
||||
if (event.eventType === EventType.LOGIN_FAILURE) {
|
||||
console.error('❌ LOGIN_FAILURE event:', event.error);
|
||||
}
|
||||
if (event.eventType === EventType.ACQUIRE_TOKEN_SUCCESS) {
|
||||
console.log('🔑 Token acquis pour:', event.payload?.account?.username);
|
||||
}
|
||||
if (event.eventType === EventType.HANDLE_REDIRECT_END) {
|
||||
console.log('🏁 HANDLE_REDIRECT_END');
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ Définir le compte actif si disponible
|
||||
const accounts = msalInstance.getAllAccounts();
|
||||
if (accounts.length > 0) {
|
||||
console.log('📊 Comptes MSAL trouvés:', accounts.length);
|
||||
msalInstance.setActiveAccount(accounts[0]);
|
||||
console.log('✅ Compte actif défini:', accounts[0].username);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors de l\'initialisation MSAL:', error);
|
||||
|
||||
// En cas d'erreur, nettoyer et continuer
|
||||
sessionStorage.removeItem('oauth_hash_backup');
|
||||
sessionStorage.removeItem('oauth_url_backup');
|
||||
sessionStorage.removeItem('oauth_capture_time');
|
||||
|
||||
// Nettoyer l'URL si elle contient encore le code
|
||||
if (window.location.hash.includes('code=')) {
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Rendre l'application React APRÈS l'initialisation MSAL
|
||||
console.log('🎨 Rendu de l\'application React...');
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<MsalProvider instance={msalInstance}>
|
||||
<App />
|
||||
</MsalProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Lancer l'initialisation
|
||||
initializeApp();
|
||||
1751
GTA_P_V2/project/src/pages/Calendar.jsx
Normal file
496
GTA_P_V2/project/src/pages/Collaborateur.jsx
Normal file
@@ -0,0 +1,496 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import { Users, CheckCircle, XCircle, Clock, Calendar, FileText, Menu, Eye, MessageSquare } from 'lucide-react';
|
||||
|
||||
const Collaborateur = () => {
|
||||
const { user } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const isEmployee = user?.role === 'Collaborateur' || 'Apprenti';
|
||||
const [teamMembers, setTeamMembers] = useState([]);
|
||||
const [pendingRequests, setPendingRequests] = useState([]);
|
||||
const [allRequests, setAllRequests] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
||||
const [showValidationModal, setShowValidationModal] = useState(false);
|
||||
const [validationComment, setValidationComment] = useState('');
|
||||
const [validationAction, setValidationAction] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
fetchTeamData();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchTeamData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Récupérer les membres de l'équipe
|
||||
await fetchTeamMembers();
|
||||
|
||||
// Récupérer les demandes en attente
|
||||
await fetchPendingRequests();
|
||||
|
||||
// Récupérer toutes les demandes de l'équipe
|
||||
await fetchAllTeamRequests();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des données équipe:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTeamMembers = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/getTeamMembers?manager_id=${user.id}`);
|
||||
const text = await response.text();
|
||||
console.log('Réponse équipe:', text);
|
||||
|
||||
const data = JSON.parse(text);
|
||||
if (data.success) {
|
||||
setTeamMembers(data.team_members || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur récupération équipe:', error);
|
||||
setTeamMembers([]);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPendingRequests = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/getPendingRequests?manager_id=${user.id}`);
|
||||
const text = await response.text();
|
||||
console.log('Réponse demandes en attente:', text);
|
||||
|
||||
const data = JSON.parse(text);
|
||||
if (data.success) {
|
||||
setPendingRequests(data.requests || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur récupération demandes en attente:', error);
|
||||
setPendingRequests([]);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllTeamRequests = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/getAllTeamRequests?SuperieurId=${user.id}`);
|
||||
const text = await response.text();
|
||||
console.log('Réponse toutes demandes équipe:', text);
|
||||
|
||||
const data = JSON.parse(text);
|
||||
if (data.success) {
|
||||
setAllRequests(data.requests || []);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
console.error('Erreur récupération toutes demandes:', error);
|
||||
console.log('Réponse brute:', text);
|
||||
setAllRequests([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateRequest = async (requestId, action, comment = '') => {
|
||||
try {
|
||||
const response = await fetch('/api/validateRequest', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_id: requestId,
|
||||
action: action, // 'approve' ou 'reject'
|
||||
comment: comment,
|
||||
validator_id: user.id
|
||||
}),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
console.log('Réponse validation:', text);
|
||||
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (data.success) {
|
||||
// Rafraîchir les données
|
||||
await fetchTeamData();
|
||||
setShowValidationModal(false);
|
||||
setSelectedRequest(null);
|
||||
setValidationComment('');
|
||||
|
||||
alert(`Demande ${action === 'approve' ? 'approuvée' : 'refusée'} avec succès !`);
|
||||
} else {
|
||||
alert(`Erreur: ${data.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur validation:', error);
|
||||
alert('Erreur lors de la validation');
|
||||
}
|
||||
};
|
||||
|
||||
const openValidationModal = (request, action) => {
|
||||
setSelectedRequest(request);
|
||||
setValidationAction(action);
|
||||
setValidationComment('');
|
||||
setShowValidationModal(true);
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'En attente': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'Validée':
|
||||
case 'Approuvé': return 'bg-green-100 text-green-800';
|
||||
case 'Refusée': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
switch (type) {
|
||||
case 'Congés payés':
|
||||
case 'Congé payé': return 'bg-blue-100 text-blue-800';
|
||||
case 'RTT': return 'bg-green-100 text-green-800';
|
||||
case 'Congé maladie': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
|
||||
<div className="lg:ml-60 flex items-center justify-center min-h-screen">
|
||||
<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 des données équipe...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="flex-1 lg:ml-60">
|
||||
<div className="p-4 lg:p-8 w-full">
|
||||
{/* Mobile menu button */}
|
||||
<div className="lg:hidden mb-4">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-lg bg-white shadow-sm border border-gray-200"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900 mb-2">
|
||||
{isEmployee ? 'Mon équipe 👥' : 'Gestion d\'équipe 👥'}
|
||||
</h1>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6 mb-8">
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs lg:text-sm font-medium text-gray-600">Équipe</p>
|
||||
<p className="text-xl lg:text-2xl font-bold text-gray-900">{teamMembers.length}</p>
|
||||
<p className="text-xs text-gray-500">membres</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 lg:w-12 lg:h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Users className="w-4 h-4 lg:w-6 lg:h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs lg:text-sm font-medium text-gray-600">En attente</p>
|
||||
<p className="text-xl lg:text-2xl font-bold text-gray-900">{pendingRequests.length}</p>
|
||||
<p className="text-xs text-gray-500">demandes</p>
|
||||
</div>
|
||||
<div className="w-8 h-8 lg:w-12 lg:h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<Clock className="w-4 h-4 lg:w-6 lg:h-6 text-yellow-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Demandes en attente */}
|
||||
{!isEmployee && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="p-4 lg:p-6 border-b border-gray-100">
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-yellow-600" />
|
||||
Demandes en attente ({pendingRequests.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 lg:p-6">
|
||||
{pendingRequests.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Clock className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600">Aucune demande en attente</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{pendingRequests.map((request) => (
|
||||
<div key={request.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-gray-900">{request.employee_name}</h3>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(request.type)}`}>
|
||||
{request.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{request.date_display}</p>
|
||||
<p className="text-xs text-gray-500">Soumis le {request.submitted_display}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-gray-900">{request.days}j</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{request.reason && (
|
||||
<div className="mb-3 p-2 bg-gray-50 rounded text-sm text-gray-700">
|
||||
<strong>Motif:</strong> {request.reason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => openValidationModal(request, 'approve')}
|
||||
className="flex-1 bg-green-600 text-white px-3 py-2 rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Approuver
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openValidationModal(request, 'reject')}
|
||||
className="flex-1 bg-red-600 text-white px-3 py-2 rounded-lg hover:bg-red-700 transition-colors flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
Refuser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Équipe */}
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-100 ${isEmployee ? 'lg:col-span-2' : ''}`}>
|
||||
<div className="p-4 lg:p-6 border-b border-gray-100">
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-blue-600" />
|
||||
Mon équipe ({teamMembers.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 lg:p-6">
|
||||
{teamMembers.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Users className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600">Aucun membre d'équipe</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{teamMembers.map((member) => (
|
||||
<div key={member.id} className={`flex items-center justify-between p-3 bg-gray-50 rounded-lg ${isEmployee ? 'lg:p-4' : ''}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-medium text-sm">
|
||||
{member.prenom?.charAt(0)}{member.nom?.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{member.prenom} {member.nom}</p>
|
||||
<p className="text-sm text-gray-600">{member.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isEmployee && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{allRequests.filter(r => r.employee_id === member.id && r.status === 'En attente').length} en attente
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{allRequests.filter(r => r.employee_id === member.id).length} total
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Historique des demandes */}
|
||||
{!isEmployee && (
|
||||
<div className="mt-6 bg-white rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="p-4 lg:p-6 border-b border-gray-100">
|
||||
<h2 className="text-lg lg:text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-gray-600" />
|
||||
Historique des demandes ({allRequests.length})
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 lg:p-6">
|
||||
{allRequests.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600">Aucune demande</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-80 overflow-y-auto">
|
||||
{allRequests.map((request) => (
|
||||
<div key={request.id} className="p-3 border border-gray-100 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="font-medium text-gray-900">{request.employee_name}</p>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(request.type)}`}>
|
||||
{request.type}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(request.status)}`}>
|
||||
{request.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{request.date_display}</p>
|
||||
<p className="text-xs text-gray-500 mb-2">Soumis le {request.submitted_display}</p>
|
||||
|
||||
{request.reason && (
|
||||
<p className="text-sm text-gray-700 mb-1"><strong>Motif :</strong> {request.reason}</p>
|
||||
)}
|
||||
|
||||
{request.file && (
|
||||
<div className="text-sm mt-1">
|
||||
<p className="text-gray-500">Document joint</p>
|
||||
<a
|
||||
href={`/uploads/${request.file}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline flex items-center gap-1 mt-1"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Voir le fichier
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-right mt-2">
|
||||
<p className="font-medium text-gray-900">{request.days}j</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal de validation */}
|
||||
|
||||
{showValidationModal && selectedRequest && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{validationAction === 'approve' ? 'Approuver' : 'Refuser'} la demande
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Corps du contenu */}
|
||||
<div className="p-6">
|
||||
<div className="mb-4 p-4 bg-gray-50 rounded-lg">
|
||||
<p className="font-medium text-gray-900">{selectedRequest.employee_name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedRequest.type} - {selectedRequest.date_display}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">{selectedRequest.days} jour(s)</p>
|
||||
|
||||
{selectedRequest.reason && (
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
<strong>Motif:</strong> {selectedRequest.reason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedRequest.file && (
|
||||
<div>
|
||||
<p className="text-gray-500">Document joint</p>
|
||||
<a
|
||||
href={`/GTA/project/uploads/${selectedRequest.file}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline flex items-center gap-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Voir le fichier
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Champ commentaire */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Commentaire {validationAction === 'reject' ? '(obligatoire)' : '(optionnel)'}
|
||||
</label>
|
||||
<textarea
|
||||
value={validationComment}
|
||||
onChange={(e) => setValidationComment(e.target.value)}
|
||||
placeholder={validationAction === 'approve' ? 'Commentaire optionnel...' : 'Motif du refus...'}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Boutons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowValidationModal(false)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleValidateRequest(selectedRequest.id, validationAction, validationComment)
|
||||
}
|
||||
disabled={validationAction === 'reject' && !validationComment.trim()}
|
||||
className={`flex-1 px-4 py-2 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${validationAction === 'approve'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-red-600 hover:bg-red-700'
|
||||
}`}
|
||||
>
|
||||
{validationAction === 'approve' ? 'Approuver' : 'Refuser'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collaborateur;
|
||||
1108
GTA_P_V2/project/src/pages/CompteRenduActivite.jsx
Normal file
1232
GTA_P_V2/project/src/pages/Dashboard.jsx
Normal file
317
GTA_P_V2/project/src/pages/EmployeeDetails.jsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ArrowLeft,
|
||||
Mail,
|
||||
Briefcase,
|
||||
Building,
|
||||
TrendingDown,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
|
||||
const EmployeeDetails = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [employee, setEmployee] = useState(null);
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [detailedCounters, setDetailedCounters] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmployeeData();
|
||||
}, [id]);
|
||||
|
||||
const fetchEmployeeData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const resEmployee = await fetch(`/api/getEmploye?id=${id}`);
|
||||
const dataEmployee = await resEmployee.json();
|
||||
|
||||
if (!dataEmployee.success) {
|
||||
setEmployee(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setEmployee(dataEmployee.employee);
|
||||
|
||||
const resCounters = await fetch(`/api/getDetailedLeaveCounters?user_id=${id}`);
|
||||
const dataCounters = await resCounters.json();
|
||||
|
||||
if (dataCounters.success) {
|
||||
setDetailedCounters(dataCounters.data);
|
||||
}
|
||||
|
||||
const resRequests = await fetch(`/api/getEmployeRequest?id=${id}`);
|
||||
const dataRequests = await resRequests.json();
|
||||
|
||||
if (dataRequests.success) {
|
||||
setRequests(dataRequests.requests || []);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Erreur récupération données collaborateur:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (status) => {
|
||||
switch (status) {
|
||||
case 'Validée':
|
||||
return {
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
bg: 'bg-emerald-50',
|
||||
text: 'text-emerald-700',
|
||||
dot: 'bg-emerald-500'
|
||||
};
|
||||
case 'Refusée':
|
||||
case 'Annulée':
|
||||
return {
|
||||
icon: <XCircle className="w-4 h-4" />,
|
||||
bg: 'bg-red-50',
|
||||
text: 'text-red-700',
|
||||
dot: 'bg-red-500'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <Clock className="w-4 h-4" />,
|
||||
bg: 'bg-amber-50',
|
||||
text: 'text-amber-700',
|
||||
dot: 'bg-amber-500'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeContratLabel = (type) => {
|
||||
switch (type) {
|
||||
case '37h': return '37h/sem';
|
||||
case 'forfait_jour': return 'Forfait jour';
|
||||
case 'temps_partiel': return 'Temps partiel';
|
||||
default: return type || '37h/sem';
|
||||
}
|
||||
};
|
||||
|
||||
const CounterCard = ({ label, solde, acquis, pris, color, icon: Icon }) => {
|
||||
const colorClasses = {
|
||||
blue: { bg: 'bg-blue-500', light: 'bg-blue-50', text: 'text-blue-600', border: 'border-blue-200' },
|
||||
cyan: { bg: 'bg-cyan-500', light: 'bg-cyan-50', text: 'text-cyan-600', border: 'border-cyan-200' },
|
||||
green: { bg: 'bg-emerald-500', light: 'bg-emerald-50', text: 'text-emerald-600', border: 'border-emerald-200' },
|
||||
purple: { bg: 'bg-violet-500', light: 'bg-violet-50', text: 'text-violet-600', border: 'border-violet-200' },
|
||||
};
|
||||
|
||||
const c = colorClasses[color] || colorClasses.blue;
|
||||
|
||||
return (
|
||||
<div className={`relative bg-white rounded-2xl border ${c.border} p-5 hover:shadow-md transition-shadow`}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 mb-1">{label}</p>
|
||||
<p className={`text-3xl font-bold ${c.text}`}>{solde.toFixed(1)}<span className="text-lg ml-1">j</span></p>
|
||||
</div>
|
||||
<div className={`${c.bg} p-2.5 rounded-xl`}>
|
||||
<Icon className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span className="text-gray-600">Acquis:</span>
|
||||
<span className="font-semibold text-gray-900">{acquis.toFixed(1)}j</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TrendingDown className="w-3.5 h-3.5 text-red-400" />
|
||||
<span className="text-gray-600">Pris:</span>
|
||||
<span className="font-semibold text-gray-900">{pris.toFixed(1)}j</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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-10 w-10 border-b-2 border-cyan-600 mx-auto mb-3"></div>
|
||||
<p className="text-gray-600">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!employee) return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600 mb-4">Collaborateur introuvable</p>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-cyan-600 hover:underline"
|
||||
>
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex-1 lg:ml-60 p-6 lg:p-8">
|
||||
{/* Bouton retour */}
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Retour</span>
|
||||
</button>
|
||||
|
||||
{/* Profil employé */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-cyan-400 to-blue-500 rounded-2xl flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-2xl font-bold text-white">
|
||||
{employee.Prenom?.charAt(0)}{employee.Nom?.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Infos */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-1">
|
||||
{employee.Prenom} {employee.Nom}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Mail className="w-4 h-4 text-gray-400" />
|
||||
<span>{employee.Email}</span>
|
||||
</div>
|
||||
|
||||
{detailedCounters?.user?.role && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Briefcase className="w-4 h-4 text-gray-400" />
|
||||
<span>{detailedCounters.user.role}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailedCounters?.user?.service && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building className="w-4 h-4 text-gray-400" />
|
||||
<span>{detailedCounters.user.service}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badge contrat */}
|
||||
{detailedCounters?.user?.typeContrat && (
|
||||
<div className="px-3 py-1.5 bg-gray-100 rounded-lg text-sm font-medium text-gray-700">
|
||||
{getTypeContratLabel(detailedCounters.user.typeContrat)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compteurs */}
|
||||
{detailedCounters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{detailedCounters.cpN1 && (
|
||||
<CounterCard
|
||||
label="CP N-1"
|
||||
solde={detailedCounters.cpN1.solde}
|
||||
acquis={detailedCounters.cpN1.reporte}
|
||||
pris={detailedCounters.cpN1.pris}
|
||||
color="blue"
|
||||
icon={Calendar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailedCounters.cpN && (
|
||||
<CounterCard
|
||||
label="CP N"
|
||||
solde={detailedCounters.cpN.solde}
|
||||
acquis={detailedCounters.cpN.acquis}
|
||||
pris={detailedCounters.cpN.pris}
|
||||
color="cyan"
|
||||
icon={Calendar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailedCounters.rttN && detailedCounters.user?.role !== 'Apprenti' && (
|
||||
<CounterCard
|
||||
label={`RTT ${detailedCounters.rttN.annee}`}
|
||||
solde={detailedCounters.rttN.solde}
|
||||
acquis={detailedCounters.rttN.acquis}
|
||||
pris={detailedCounters.rttN.pris}
|
||||
color="green"
|
||||
icon={Clock}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailedCounters.recupN && (
|
||||
<CounterCard
|
||||
label="Récupérations"
|
||||
solde={detailedCounters.recupN.solde}
|
||||
acquis={detailedCounters.recupN.acquis}
|
||||
pris={detailedCounters.recupN.pris}
|
||||
color="purple"
|
||||
icon={Clock}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historique */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Historique des demandes</h2>
|
||||
<p className="text-sm text-gray-500">{requests.length} demande{requests.length > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-100">
|
||||
{requests.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<Calendar className="w-12 h-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">Aucune demande de congés</p>
|
||||
</div>
|
||||
) : (
|
||||
requests.map((r) => {
|
||||
const statusConfig = getStatusConfig(r.status);
|
||||
return (
|
||||
<div key={r.Id} className="px-6 py-4 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-2 h-2 rounded-full ${statusConfig.dot}`}></div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{r.type}</p>
|
||||
<p className="text-sm text-gray-500">{r.date_display}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-gray-700">{r.days}j</span>
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusConfig.bg} ${statusConfig.text}`}>
|
||||
{statusConfig.icon}
|
||||
{r.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployeeDetails;
|
||||
153
GTA_P_V2/project/src/pages/Login.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
const Login = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { loginWithO365, isAuthorized, isLoading: authLoading } = useAuth();
|
||||
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
|
||||
// ✅ AJOUT : Redirection automatique si déjà connecté (cas retour OAuth mobile)
|
||||
useEffect(() => {
|
||||
if (isAuthorized && !authLoading) {
|
||||
console.log('✅ Utilisateur autorisé détecté, redirection vers dashboard...');
|
||||
navigate('/dashboard', { replace: true });
|
||||
}
|
||||
}, [isAuthorized, authLoading, navigate]);
|
||||
|
||||
const handleO365Login = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (isMobile) {
|
||||
console.log('🔐 Redirection mobile vers Office 365...');
|
||||
await loginWithO365();
|
||||
// Ce code ne sera jamais atteint sur mobile car il y a une redirection
|
||||
} else {
|
||||
const success = await loginWithO365();
|
||||
|
||||
if (!success) {
|
||||
setError("Erreur lors de la connexion Office 365");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/dashboard');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur O365:', error);
|
||||
|
||||
if (error.message?.includes('non autorisé') || error.message?.includes('Accès refusé')) {
|
||||
setError('Accès refusé : Vous devez être membre d\'un groupe autorisé dans votre organisation.');
|
||||
} else if (error.message?.includes('AADSTS')) {
|
||||
setError('Erreur d\'authentification Azure AD. Contactez votre administrateur.');
|
||||
} else if (error.errorCode === 'user_cancelled') {
|
||||
setError('Connexion annulée');
|
||||
} else {
|
||||
setError(error.message || "Erreur lors de la connexion Office 365");
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ AJOUT : Afficher un loader pendant la vérification de l'auth
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-cyan-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Vérification de la connexion...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex flex-col lg:flex-row">
|
||||
{/* Image côté gauche */}
|
||||
<div className="h-32 lg:h-auto lg:flex lg:w-1/2 bg-cover bg-center"
|
||||
style={{ backgroundImage: "url('/assets/ImageEnsup.png')" }}>
|
||||
<div className="w-full bg-black bg-opacity-40 flex items-center justify-center p-4">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formulaire côté droit */}
|
||||
<div className="flex-1 lg:w-1/2 flex items-center justify-center p-4 lg:p-8">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-6 lg:p-8">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-6">
|
||||
<img
|
||||
src="/assets/GA.svg"
|
||||
alt="GTA Logo"
|
||||
className="h-36 lg:h-40 w-auto mx-auto"
|
||||
/>
|
||||
<p className="text-lg lg:text-xl font-semibold mb-6" style={{ color: '#7e5aa2' }}>
|
||||
GESTION DES TEMPS ET DES ACTIVITÉS
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bouton Office 365 */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
data-testid="o365-login-btn"
|
||||
onClick={handleO365Login}
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
className="w-full bg-cyan-600 text-white py-3 rounded-lg font-medium hover:bg-cyan-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span>Connexion en cours...</span>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" viewBox="0 0 21 21" fill="currentColor">
|
||||
<path d="M10.5 0L0 7v7l10.5 7L21 14V7L10.5 0zM3.5 8.5L10.5 3l7 5.5v5L10.5 19l-7-5.5v-5z" />
|
||||
</svg>
|
||||
<span>Se connecter avec Office 365</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message d'information */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-center">
|
||||
<p className="text-blue-800 text-sm">
|
||||
Connectez-vous avec votre compte professionnel ENSUP
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Message d'erreur */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg mt-4">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-red-700 text-sm font-medium">
|
||||
{error.includes('Accès refusé') ? 'Accès refusé' : 'Erreur de connexion'}
|
||||
</p>
|
||||
<p className="text-red-600 text-xs mt-1">{error}</p>
|
||||
{error.includes('groupe autorisé') && (
|
||||
<p className="text-red-600 text-xs mt-2">
|
||||
Contactez votre administrateur pour être ajouté aux groupes appropriés.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
559
GTA_P_V2/project/src/pages/Manager.jsx
Normal file
@@ -0,0 +1,559 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Sidebar from "../components/Sidebar";
|
||||
import GlobalTutorial from '../components/GlobalTutorial';
|
||||
import {
|
||||
Users,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
FileText,
|
||||
Eye,
|
||||
Check,
|
||||
X,
|
||||
MessageSquare,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
const Manager = () => {
|
||||
const { user } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const isEmployee = user?.role === "Collaborateur" || user?.role === "Apprenti";
|
||||
|
||||
const [teamMembers, setTeamMembers] = useState([]);
|
||||
const [pendingRequests, setPendingRequests] = useState([]);
|
||||
const [allRequests, setAllRequests] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const [toast, setToast] = useState(null);
|
||||
const [validationModal, setValidationModal] = useState(null);
|
||||
const [comment, setComment] = useState("");
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id) fetchTeamData();
|
||||
}, [user]);
|
||||
|
||||
const fetchTeamData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await Promise.all([
|
||||
fetchTeamMembers(),
|
||||
fetchPendingRequests(),
|
||||
fetchAllTeamRequests(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ SIMPLIFIÉ - Le backend gère tout le filtrage
|
||||
// ✅ SIMPLIFIÉ - Le backend gère tout le filtrage
|
||||
const fetchTeamMembers = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/getTeamMembers?manager_id=${user.id}`);
|
||||
const data = await res.json();
|
||||
|
||||
console.log('📊 getTeamMembers:', {
|
||||
success: data.success,
|
||||
count: data.team_members?.length || 0,
|
||||
role: user.role,
|
||||
service: user.service,
|
||||
campus: user.campus
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setTeamMembers(data.team_members || []);
|
||||
} else {
|
||||
console.error('❌ Erreur getTeamMembers:', data.message);
|
||||
setTeamMembers([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur fetch getTeamMembers:', error);
|
||||
setTeamMembers([]);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ SIMPLIFIÉ - Le backend gère tout le filtrage
|
||||
const fetchPendingRequests = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/getPendingRequests?manager_id=${user.id}`);
|
||||
const data = await res.json();
|
||||
|
||||
console.log('📊 getPendingRequests:', {
|
||||
success: data.success,
|
||||
count: data.requests?.length || 0,
|
||||
role: user.role
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setPendingRequests(data.requests || []);
|
||||
} else {
|
||||
console.error('❌ Erreur getPendingRequests:', data.message);
|
||||
setPendingRequests([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur fetch getPendingRequests:', error);
|
||||
setPendingRequests([]);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ SIMPLIFIÉ - Le backend gère tout le filtrage
|
||||
const fetchAllTeamRequests = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/getAllTeamRequests?SuperieurId=${user.id}`);
|
||||
const data = await res.json();
|
||||
|
||||
console.log('📊 getAllTeamRequests:', {
|
||||
success: data.success,
|
||||
count: data.requests?.length || 0,
|
||||
role: user.role
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setAllRequests(data.requests || []);
|
||||
} else {
|
||||
console.error('❌ Erreur getAllTeamRequests:', data.message);
|
||||
setAllRequests([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur fetch getAllTeamRequests:', error);
|
||||
setAllRequests([]);
|
||||
}
|
||||
};
|
||||
|
||||
const openValidationModal = (request, action) => {
|
||||
setValidationModal({ request, action });
|
||||
setComment("");
|
||||
setIsValidating(false);
|
||||
};
|
||||
|
||||
const closeValidationModal = () => {
|
||||
if (isValidating) return;
|
||||
setValidationModal(null);
|
||||
setComment("");
|
||||
};
|
||||
|
||||
const confirmValidation = async () => {
|
||||
const { request, action } = validationModal;
|
||||
|
||||
if (action === "reject" && !comment.trim()) {
|
||||
showToast("error", "Un commentaire est obligatoire pour refuser une demande");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValidating) return;
|
||||
|
||||
setIsValidating(true);
|
||||
|
||||
try {
|
||||
await handleValidateRequest(request.id, action, comment);
|
||||
showToast("success", action === "approve" ? "Demande approuvée avec succès" : "Demande refusée");
|
||||
closeValidationModal();
|
||||
} catch (error) {
|
||||
showToast("error", "Une erreur est survenue");
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidateRequest = async (requestId, action, comment = '') => {
|
||||
if (!user || !user.id) {
|
||||
throw new Error('Utilisateur non identifié');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/validateRequest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
request_id: requestId,
|
||||
action: action,
|
||||
validator_id: user.id,
|
||||
comment: comment
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Erreur lors de la validation');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fetchPendingRequests(),
|
||||
fetchAllTeamRequests()
|
||||
]);
|
||||
};
|
||||
|
||||
const showToast = (type, message) => {
|
||||
setToast({ type, message });
|
||||
setTimeout(() => setToast(null), 4000);
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case "En attente": return "bg-yellow-100 text-yellow-800";
|
||||
case "Validée":
|
||||
case "Approuvé": return "bg-green-100 text-green-800";
|
||||
case "Refusée": return "bg-red-100 text-red-800";
|
||||
default: return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
switch (type) {
|
||||
case "Congés payés":
|
||||
case "Congé payé": return "bg-blue-100 text-blue-800";
|
||||
case "RTT": return "bg-green-100 text-green-800";
|
||||
case "Congé maladie": return "bg-red-100 text-red-800";
|
||||
default: return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const EmptyBackground = ({ icon: Icon, title, subtitle }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-b from-gray-50 to-gray-100 text-gray-500 pointer-events-none"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, -8, 0] }}
|
||||
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="bg-gray-200 p-5 rounded-full shadow-inner mb-4"
|
||||
>
|
||||
<Icon className="w-12 h-12 text-gray-400" />
|
||||
</motion.div>
|
||||
<h2 className="text-xl font-semibold mb-1 text-gray-700">{title}</h2>
|
||||
<p className="text-sm text-gray-500">{subtitle}</p>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
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-10 w-10 border-b-2 border-blue-600 mx-auto mb-3"></div>
|
||||
<p className="text-gray-600">Chargement des données...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-gray-50 flex overflow-hidden">
|
||||
{/* Toast Notification */}
|
||||
<AnimatePresence>
|
||||
{toast && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
className="fixed top-6 left-1/2 transform -translate-x-1/2 z-50 max-w-md w-full mx-4"
|
||||
>
|
||||
<div className={`rounded-xl shadow-2xl p-4 flex items-center gap-3 backdrop-blur-sm border-2 ${toast.type === "success" ? "bg-green-50 border-green-500 text-green-900" : "bg-red-50 border-red-500 text-red-900"
|
||||
}`}>
|
||||
<div className={`p-2 rounded-full ${toast.type === "success" ? "bg-green-500" : "bg-red-500"}`}>
|
||||
{toast.type === "success" ? (
|
||||
<Check className="w-5 h-5 text-white" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-sm">{toast.message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setToast(null)}
|
||||
className={`p-1 rounded-lg transition ${toast.type === "success" ? "hover:bg-green-200" : "hover:bg-red-200"}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Modal de validation */}
|
||||
<AnimatePresence>
|
||||
{validationModal && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
onClick={closeValidationModal}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-white rounded-xl shadow-2xl max-w-md w-full"
|
||||
>
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-full ${validationModal.action === "approve" ? "bg-green-100" : "bg-red-100"
|
||||
}`}>
|
||||
{validationModal.action === "approve" ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-6 h-6 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{validationModal.action === "approve" ? "Approuver la demande" : "Refuser la demande"}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">{validationModal.request.employee_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Type</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(validationModal.request.type)}`}>
|
||||
{validationModal.request.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Période</span>
|
||||
<span className="text-sm font-medium text-gray-900">{validationModal.request.date_display}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Durée</span>
|
||||
<span className="text-sm font-medium text-gray-900">{validationModal.request.days} jour(s)</span>
|
||||
</div>
|
||||
{validationModal.request.reason && (
|
||||
<div className="pt-2 border-t border-gray-200">
|
||||
<p className="text-xs text-gray-500 mb-1">Motif :</p>
|
||||
<p className="text-sm text-gray-700">{validationModal.request.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
Commentaire{" "}
|
||||
{validationModal.action === "reject" && <span className="text-red-600">*</span>}
|
||||
{validationModal.action === "approve" && <span className="text-gray-400 font-normal">(optionnel)</span>}
|
||||
</label>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder={validationModal.action === "approve" ? "Ajouter un commentaire..." : "Expliquer le motif du refus..."}
|
||||
rows={4}
|
||||
disabled={isValidating}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:outline-none resize-none transition ${isValidating ? 'bg-gray-100 cursor-not-allowed' : ''
|
||||
} ${validationModal.action === "reject" && !comment.trim()
|
||||
? "border-red-300 focus:ring-red-500 focus:border-red-500"
|
||||
: "border-gray-300 focus:ring-blue-500 focus:border-blue-500"
|
||||
}`}
|
||||
/>
|
||||
{validationModal.action === "reject" && !comment.trim() && (
|
||||
<p className="text-xs text-red-600 mt-1">Un commentaire est obligatoire pour un refus</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-100 flex gap-3">
|
||||
<button
|
||||
onClick={closeValidationModal}
|
||||
disabled={isValidating}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmValidation}
|
||||
disabled={isValidating || (validationModal.action === "reject" && !comment.trim())}
|
||||
className={`flex-1 px-4 py-2 text-white rounded-lg transition font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 ${validationModal.action === "approve" ? "bg-green-600 hover:bg-green-700" : "bg-red-600 hover:bg-red-700"
|
||||
}`}
|
||||
>
|
||||
{isValidating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Traitement...</span>
|
||||
</>
|
||||
) : (
|
||||
validationModal.action === "approve" ? "Approuver" : "Refuser"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Fond animé si aucune donnée */}
|
||||
{!isLoading && teamMembers.length === 0 && pendingRequests.length === 0 && allRequests.length === 0 && (
|
||||
<EmptyBackground
|
||||
icon={Users}
|
||||
title="Bienvenue dans la gestion d'équipe 👋"
|
||||
subtitle="Les demandes et collaborateurs apparaîtront ici dès qu'ils seront disponibles."
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="flex-1 lg:ml-60 p-6 space-y-8 relative z-10">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isEmployee ? "Mon équipe 👥" : "Gestion d'équipe 👥"}
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{!isEmployee && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100" data-tour="demandes-attente">
|
||||
<div className="p-4 border-b border-gray-100 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-yellow-600" />
|
||||
<h2 className="font-semibold text-gray-900">Demandes en attente ({pendingRequests.length})</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{pendingRequests.length === 0 ? (
|
||||
<p className="text-center text-gray-500">Aucune demande en attente</p>
|
||||
) : (
|
||||
pendingRequests.map((r) => (
|
||||
<div key={r.id} className="border p-4 rounded-lg bg-gray-50 hover:bg-gray-100 transition">
|
||||
<div className="flex justify-between mb-2">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{r.employee_name}</p>
|
||||
<p className="text-sm text-gray-600">{r.date_display}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(r.type)}`}>
|
||||
{r.type}
|
||||
</span>
|
||||
</div>
|
||||
{r.reason && (
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
<strong>Motif:</strong> {r.reason}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => openValidationModal(r, "approve")}
|
||||
disabled={isValidating}
|
||||
className="flex-1 bg-green-600 text-white px-3 py-2 rounded-lg hover:bg-green-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
data-tour="approuver-btn">
|
||||
<CheckCircle className="w-4 h-4 inline mr-1" />
|
||||
Approuver
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openValidationModal(r, "reject")}
|
||||
disabled={isValidating}
|
||||
className="flex-1 bg-red-600 text-white px-3 py-2 rounded-lg hover:bg-red-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
data-tour="refuser-btn">
|
||||
<XCircle className="w-4 h-4 inline mr-1" />
|
||||
Refuser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`bg-white rounded-xl shadow-sm border border-gray-100 ${isEmployee ? "lg:col-span-2" : ""}`} data-tour="mon-equipe">
|
||||
<div className="p-4 border-b border-gray-100 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-blue-600" />
|
||||
<h2 className="font-semibold text-gray-900">Mon équipe ({teamMembers.length})</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{teamMembers.length === 0 ? (
|
||||
<p className="text-center text-gray-500">Aucun membre d'équipe</p>
|
||||
) : (
|
||||
teamMembers.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => navigate(`/employee/${m.id}`)}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer transition"
|
||||
data-tour="membre-equipe">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-medium text-sm">
|
||||
{m.prenom?.charAt(0)}{m.nom?.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{m.prenom} {m.nom}</p>
|
||||
<p className="text-sm text-gray-600">{m.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isEmployee && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{allRequests.filter((r) => r.employee_id === m.id && r.status === "En attente").length} en attente
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{allRequests.filter((r) => r.employee_id === m.id).length} total
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEmployee && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 mt-6" data-tour="historique-demandes">
|
||||
<div className="p-4 border-b border-gray-100 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-gray-600" />
|
||||
<h2 className="font-semibold text-gray-900">Historique des demandes ({allRequests.length})</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-3 max-h-80 overflow-y-auto">
|
||||
{allRequests.length === 0 ? (
|
||||
<p className="text-center text-gray-500">Aucune demande</p>
|
||||
) : (
|
||||
allRequests.map((r) => (
|
||||
<div key={r.id} className="p-3 border border-gray-100 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<p className="font-medium text-gray-900">{r.employee_name}</p>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(r.type)}`}>
|
||||
{r.type}
|
||||
</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(r.status)}`}>
|
||||
{r.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{r.date_display}</p>
|
||||
<p className="text-xs text-gray-500 mb-2">Soumis le {r.submitted_display}</p>
|
||||
{r.reason && (
|
||||
<p className="text-sm text-gray-700 mb-1">
|
||||
<strong>Motif :</strong> {r.reason}
|
||||
</p>
|
||||
)}
|
||||
{r.file && (
|
||||
<div className="text-sm mt-1" data-tour="document-joint">
|
||||
<p className="text-gray-500">Document joint</p>
|
||||
<a
|
||||
href={`/uploads/${r.file}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline flex items-center gap-1 mt-1"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Voir le fichier
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<GlobalTutorial userId={user?.id} userRole={user?.role} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Manager;
|
||||
924
GTA_P_V2/project/src/pages/Requests.jsx
Normal file
@@ -0,0 +1,924 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import { Plus, Search, Filter, Eye, Menu, X, RefreshCw, Wifi, WifiOff, Info, Edit2, Trash2 } from 'lucide-react';
|
||||
import NewLeaveRequestModal from '../components/NewLeaveRequestModal';
|
||||
import EditLeaveRequestModal from '../components/EditLeaveRequestModal';
|
||||
import { useMsal } from "@azure/msal-react";
|
||||
import MedicalDocuments from '../components/MedicalDocuments';
|
||||
import Joyride, { STATUS } from 'react-joyride';
|
||||
|
||||
|
||||
const Requests = () => {
|
||||
const { user } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [detailedCounters, setDetailedCounters] = useState(null);
|
||||
|
||||
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
||||
const [showEditRequestModal, setShowEditRequestModal] = useState(false);
|
||||
const [requestToEdit, setRequestToEdit] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const [allRequests, setAllRequests] = useState([]);
|
||||
const [filteredRequests, setFilteredRequests] = useState([]);
|
||||
const [selectedRequest, setSelectedRequest] = useState(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [requestsPerPage] = useState(10);
|
||||
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const [graphToken, setGraphToken] = useState(null);
|
||||
const { instance, accounts } = useMsal();
|
||||
const userId = user?.id || user?.CollaborateurADId || user?.ID;
|
||||
|
||||
const [lastRefresh, setLastRefresh] = useState(new Date());
|
||||
|
||||
// States pour SSE
|
||||
const [sseConnected, setSseConnected] = useState(false);
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
// ⭐ State pour la modal de confirmation de suppression
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [requestToDelete, setRequestToDelete] = useState(null);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 🎯 STATES POUR LE TUTORIEL
|
||||
const [runTour, setRunTour] = useState(false);
|
||||
|
||||
// 🎯 DÉCLENCHER LE TUTORIEL À CHAQUE FOIS
|
||||
useEffect(() => {
|
||||
if (userId && !isLoading) {
|
||||
setTimeout(() => setRunTour(true), 1500);
|
||||
}
|
||||
}, [userId, isLoading]);
|
||||
|
||||
// 🎯 DÉFINITION DES ÉTAPES DU TUTORIEL
|
||||
const tourSteps = [
|
||||
{
|
||||
target: '[data-tour="nouvelle-demande"]',
|
||||
content: '➕ Créez une nouvelle demande de congé en cliquant ici.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="recherche"]',
|
||||
content: '🔍 Recherchez vos demandes par type ou statut.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="filtres"]',
|
||||
content: '🎯 Filtrez vos demandes par statut ou type de congé.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tour="liste-demandes"]',
|
||||
content: '📋 Consultez la liste de toutes vos demandes ici.',
|
||||
placement: 'top',
|
||||
},
|
||||
];
|
||||
|
||||
// 🎯 GÉRER LA FIN DU TOUR
|
||||
const handleJoyrideCallback = (data) => {
|
||||
const { status } = data;
|
||||
const finishedStatuses = [STATUS.FINISHED, STATUS.SKIPPED];
|
||||
if (finishedStatuses.includes(status)) {
|
||||
setRunTour(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (accounts.length > 0) {
|
||||
const request = {
|
||||
scopes: ["User.Read", "Mail.Send"],
|
||||
account: accounts[0],
|
||||
};
|
||||
|
||||
instance.acquireTokenSilent(request)
|
||||
.then((response) => {
|
||||
setGraphToken(response.accessToken);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("❌ Erreur récupération token Graph:", err);
|
||||
});
|
||||
}
|
||||
}, [accounts, instance]);
|
||||
|
||||
const fetchDetailedCounters = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/getDetailedLeaveCounters?user_id=${userId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setDetailedCounters(data.data);
|
||||
} else {
|
||||
console.error("Erreur compteurs:", data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('💥 Erreur compteurs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllRequests = async () => {
|
||||
try {
|
||||
const url = `/api/getRequests?user_id=${userId}`;
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error('Le serveur PHP ne répond pas correctement');
|
||||
}
|
||||
if (data.success) {
|
||||
console.log('🔍 DEBUG - Requests reçues:', data.requests);
|
||||
if (data.requests && data.requests.length > 0) {
|
||||
console.log('🔍 DEBUG - Premier request:', data.requests[0]);
|
||||
}
|
||||
setAllRequests(data.requests || []);
|
||||
} else {
|
||||
throw new Error(data.message || 'Erreur lors de la récupération des demandes');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur requêtes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction de rafraîchissement unifiée
|
||||
const refreshAllData = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
|
||||
console.log('🔄 Rafraîchissement des données...');
|
||||
if (!isLoading) setIsRefreshing(true);
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchDetailedCounters(),
|
||||
fetchAllRequests()
|
||||
]);
|
||||
setLastRefresh(new Date());
|
||||
console.log('✅ Données rafraîchies');
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur lors du rafraîchissement:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
// Fonction pour afficher des toasts
|
||||
const showToast = useCallback((message, type = 'info') => {
|
||||
const id = Date.now();
|
||||
const newToast = { id, message, type };
|
||||
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
|
||||
// Auto-remove après 5 secondes
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
// ⭐ FONCTION CORRIGÉE : Modifier une demande
|
||||
const handleEditRequest = (request) => {
|
||||
console.log('🔍 DEBUG - Request à éditer:', request);
|
||||
console.log('🔍 DEBUG - Request ID:', request.id);
|
||||
console.log('🔍 DEBUG - Request startDate:', request.startDate);
|
||||
console.log('🔍 DEBUG - Request endDate:', request.endDate);
|
||||
console.log('🔍 DEBUG - Request type:', request.type);
|
||||
console.log('🔍 DEBUG - Request reason:', request.reason);
|
||||
|
||||
setRequestToEdit(request);
|
||||
setShowEditRequestModal(true);
|
||||
|
||||
console.log('✅ Modal d\'édition devrait s\'ouvrir');
|
||||
};
|
||||
|
||||
// ⭐ FONCTION : Supprimer/Annuler une demande
|
||||
const handleDeleteRequest = async (requestId) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
console.log('🗑️ Début annulation, ID:', requestId);
|
||||
|
||||
// Chercher la demande dans allRequests
|
||||
let request = allRequests.find(r => r.id === requestId);
|
||||
|
||||
if (!request) {
|
||||
console.log('⚠️ Demande non trouvée dans l\'état local, récupération via API...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/getRequests?user_id=${userId}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.requests) {
|
||||
request = result.requests.find(r => r.id === requestId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur récupération demande:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!request) {
|
||||
showToast('❌ Demande introuvable', 'error');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📋 Demande trouvée:', request);
|
||||
|
||||
// Vérifier la date de début
|
||||
const dateDebut = new Date(request.startDate);
|
||||
const aujourdhui = new Date();
|
||||
aujourdhui.setHours(0, 0, 0, 0);
|
||||
dateDebut.setHours(0, 0, 0, 0);
|
||||
|
||||
console.log('📅 Date début:', dateDebut.toLocaleDateString('fr-FR'));
|
||||
console.log('📅 Aujourd\'hui:', aujourdhui.toLocaleDateString('fr-FR'));
|
||||
|
||||
if (dateDebut <= aujourdhui) {
|
||||
showToast(
|
||||
`❌ Impossible d'annuler : la date de début (${dateDebut.toLocaleDateString('fr-FR')}) est déjà passée ou c'est aujourd'hui`,
|
||||
'error'
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// ⭐ CONFIRMATION AVEC MODAL
|
||||
setRequestToDelete(request);
|
||||
setShowDeleteConfirm(true);
|
||||
setIsLoading(false);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur annulation:', error);
|
||||
showToast(`Erreur: ${error.message}`, 'error');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction helper pour formater les dates
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('fr-FR');
|
||||
};
|
||||
|
||||
// ⭐ FONCTION : Confirmer la suppression
|
||||
const confirmDeleteRequest = async () => {
|
||||
if (!requestToDelete) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const requestData = {
|
||||
requestId: requestToDelete.id,
|
||||
userId: userId,
|
||||
userEmail: user.email,
|
||||
userName: `${user.prenom} ${user.nom}`,
|
||||
accessToken: graphToken
|
||||
};
|
||||
|
||||
console.log('📤 Envoi requête:', requestData);
|
||||
|
||||
const response = await fetch('/api/deleteRequest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('📥 Réponse API:', result);
|
||||
|
||||
if (result.success) {
|
||||
// ✅ Succès
|
||||
showToast('✅ Demande annulée avec succès', 'success');
|
||||
|
||||
if (result.counterRestored && result.repartition) {
|
||||
// Afficher les détails de la restauration
|
||||
const repartitionText = result.repartition
|
||||
.map(r => `${r.type}: ${r.jours}j${r.periode !== 'Journée entière' ? ` (${r.periode})` : ''}`)
|
||||
.join(', ');
|
||||
|
||||
showToast(`📊 Compteurs restaurés: ${repartitionText}`, 'info');
|
||||
}
|
||||
|
||||
if (result.emailsSent) {
|
||||
if (result.emailsSent.collaborateur) {
|
||||
showToast('📧 Email de confirmation envoyé', 'info');
|
||||
}
|
||||
if (result.emailsSent.manager) {
|
||||
showToast('📧 Manager notifié par email', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// ⭐ Rafraîchir les données
|
||||
await refreshAllData();
|
||||
|
||||
} else {
|
||||
// ❌ Erreur
|
||||
showToast(result.message || 'Erreur lors de l\'annulation', 'error');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur annulation:', error);
|
||||
showToast(`Erreur serveur: ${error.message}`, 'error');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setShowDeleteConfirm(false);
|
||||
setRequestToDelete(null);
|
||||
|
||||
// Fermer les détails si c'était la demande affichée
|
||||
if (selectedRequest?.id === requestToDelete?.id) {
|
||||
setSelectedRequest(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Connexion SSE
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
|
||||
console.log('🔌 Connexion SSE au serveur collaborateurs...');
|
||||
|
||||
const eventSource = new EventSource(`/api/events/collaborateur?user_id=${userId}`);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('✅ SSE connecté');
|
||||
setSseConnected(true);
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('📩 SSE reçu:', data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'connected':
|
||||
console.log('✅ Connexion SSE établie:', data.message);
|
||||
break;
|
||||
|
||||
case 'heartbeat':
|
||||
break;
|
||||
|
||||
case 'demande-validated-rh':
|
||||
console.log(`🔔 Validation RH reçue: ${data.statut}`);
|
||||
|
||||
if (data.statut === 'Validée') {
|
||||
showToast('✅ Votre demande a été VALIDÉE !', 'success');
|
||||
} else if (data.statut === 'Refusée') {
|
||||
showToast('❌ Votre demande a été REFUSÉE', 'error');
|
||||
}
|
||||
|
||||
refreshAllData();
|
||||
break;
|
||||
|
||||
case 'compteur-updated':
|
||||
console.log('🔄 Compteurs mis à jour');
|
||||
fetchDetailedCounters();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('📩 Événement SSE:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur parsing SSE:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('❌ Erreur SSE:', error);
|
||||
setSseConnected(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
console.log('🔌 Fermeture connexion SSE');
|
||||
eventSource.close();
|
||||
};
|
||||
}, [userId, refreshAllData, showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
refreshAllData();
|
||||
}
|
||||
}, [userId, refreshAllData]);
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = allRequests;
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(req =>
|
||||
req.type.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
req.status.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(req => req.status === statusFilter);
|
||||
}
|
||||
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter(req => req.type === typeFilter);
|
||||
}
|
||||
|
||||
setFilteredRequests(filtered);
|
||||
setCurrentPage(1);
|
||||
}, [allRequests, searchTerm, statusFilter, typeFilter]);
|
||||
|
||||
const indexOfLastRequest = currentPage * requestsPerPage;
|
||||
const indexOfFirstRequest = indexOfLastRequest - requestsPerPage;
|
||||
const currentRequests = filteredRequests.slice(indexOfFirstRequest, indexOfLastRequest);
|
||||
const totalPages = Math.ceil(filteredRequests.length / requestsPerPage);
|
||||
|
||||
const paginate = (pageNumber) => setCurrentPage(pageNumber);
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'En attente': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'Validée': return 'bg-green-100 text-green-800';
|
||||
case 'Refusée': return 'bg-red-100 text-red-800';
|
||||
case 'Annulée': return 'bg-gray-100 text-gray-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewRequest = (request) => {
|
||||
setSelectedRequest(request);
|
||||
};
|
||||
|
||||
const handleCloseDetails = () => {
|
||||
setSelectedRequest(null);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
refreshAllData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
|
||||
{/* 🎯 TUTORIEL INTERACTIF */}
|
||||
<Joyride
|
||||
steps={tourSteps}
|
||||
run={runTour}
|
||||
continuous
|
||||
showProgress={false}
|
||||
showSkipButton
|
||||
callback={handleJoyrideCallback}
|
||||
styles={{ options: { primaryColor: '#0891b2', zIndex: 10000 } }}
|
||||
tooltipComponent={({ continuous, index, step, backProps, primaryProps, skipProps, tooltipProps, size }) => (
|
||||
<div {...tooltipProps} style={{ backgroundColor: 'white', borderRadius: '12px', padding: '20px', maxWidth: '350px', boxShadow: '0 10px 25px rgba(0,0,0,0.15)', fontSize: '14px' }}>
|
||||
<div style={{ marginBottom: '15px', color: '#374151' }}>{step.content}</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: '6px 12px', borderRadius: '6px', border: '1px solid #d1d5db', backgroundColor: 'white', color: '#6b7280', cursor: 'pointer', fontSize: '13px', fontWeight: '500' }}>Retour</button>}
|
||||
{continuous && index < size - 1 && <button {...primaryProps} style={{ padding: '6px 16px', borderRadius: '6px', border: 'none', backgroundColor: '#0891b2', color: 'white', cursor: 'pointer', fontSize: '13px', fontWeight: '500' }}>Suivant</button>}
|
||||
{(!continuous || index === size - 1) && <button {...primaryProps} style={{ padding: '6px 16px', borderRadius: '6px', border: 'none', backgroundColor: '#0891b2', color: 'white', cursor: 'pointer', fontSize: '13px', fontWeight: '500' }}>Terminer</button>}
|
||||
<button {...skipProps} style={{ padding: '6px 10px', borderRadius: '6px', border: 'none', backgroundColor: 'transparent', color: '#9ca3af', cursor: 'pointer', fontSize: '12px' }}>Passer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
{/* Toast container */}
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`px-4 py-3 rounded-lg shadow-lg ${toast.type === 'success' ? 'bg-green-500 text-white' :
|
||||
toast.type === 'error' ? 'bg-red-500 text-white' :
|
||||
toast.type === 'warning' ? 'bg-yellow-500 text-white' :
|
||||
'bg-blue-500 text-white'
|
||||
} animate-slideInRight`}
|
||||
style={{ animation: 'slideInRight 0.3s ease-out' }}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
{showDeleteConfirm && requestToDelete && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50" onClick={() => !isSubmitting && setShowDeleteConfirm(false)}></div>
|
||||
<div className="relative bg-white rounded-xl shadow-xl p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900">
|
||||
⚠️ Confirmer l'annulation
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<p className="text-gray-700">
|
||||
Voulez-vous annuler cette demande de congé ?
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Type :</span>
|
||||
<span className="font-medium">{requestToDelete.type}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Période :</span>
|
||||
<span className="font-medium">{requestToDelete.dateDisplay}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Durée :</span>
|
||||
<span className="font-medium">{requestToDelete.days} jour(s)</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Statut :</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(requestToDelete.status)}`}>
|
||||
{requestToDelete.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(requestToDelete.status === 'Validée' || requestToDelete.status === 'Validé') && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-800">
|
||||
<p className="font-medium">ℹ️ Information</p>
|
||||
<p className="mt-1">Cette demande a été validée. Vos compteurs seront automatiquement restaurés.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDeleteRequest}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Annulation...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Confirmer l'annulation
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 lg:ml-64">
|
||||
<div className="p-4 lg:p-8 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setSidebarOpen(true)} className="lg:hidden p-2">
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900">Mes demandes</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className={`p-2 text-gray-600 hover:bg-gray-100 rounded-lg ${isRefreshing ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title="Rafraîchir"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
data-tour="nouvelle-demande"
|
||||
onClick={() => setShowNewRequestModal(true)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 text-sm lg:text-base"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> Nouvelle demande
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: list */}
|
||||
<div className="lg:col-span-2">
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 mb-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 relative" data-tour="recherche">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
data-tour="filtres"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Filter className="w-5 h-5" />
|
||||
Filtres
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3 pt-3 border-t">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">Tous les statuts</option>
|
||||
<option value="En attente">En attente</option>
|
||||
<option value="Validée">Validée</option>
|
||||
<option value="Refusée">Refusée</option>
|
||||
<option value="Annulée">Annulée</option>
|
||||
</select>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">Tous les types</option>
|
||||
<option value="Congé payé">Congé(s) payé(s)</option>
|
||||
<option value="RTT">RTT</option>
|
||||
<option value="Arrêt maladie">Arrêt maladie</option>
|
||||
<option value="Récupération">Récupération</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Requests list */}
|
||||
{isLoading ? (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-8 text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin mx-auto text-blue-600 mb-2" />
|
||||
<p className="text-gray-500">Chargement...</p>
|
||||
</div>
|
||||
) : currentRequests.length === 0 ? (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-8 text-center">
|
||||
<Info className="w-12 h-12 mx-auto text-gray-300 mb-3" />
|
||||
<p className="text-gray-500">Aucune demande trouvée</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3" data-tour="liste-demandes">
|
||||
{currentRequests.map((request) => (
|
||||
<div key={request.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{request.type}</h3>
|
||||
<p className="text-sm text-gray-600">{request.dateDisplay}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${getStatusColor(request.status)}`}>
|
||||
{request.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 mb-3">
|
||||
<span className="bg-gray-100 px-2 py-1 rounded">{request.days} jour(s)</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-between items-center text-sm">
|
||||
<span className="text-gray-500">{request.submittedDisplay}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* ⭐ Bouton Modifier - Plus de restriction sur le statut */}
|
||||
<button
|
||||
onClick={() => handleEditRequest(request)}
|
||||
className="text-blue-600 hover:text-blue-700 flex items-center gap-1 px-2 py-1 hover:bg-blue-50 rounded"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Modifier</span>
|
||||
</button>
|
||||
|
||||
{/* Bouton Annuler */}
|
||||
{request.status !== 'Annulée' && request.status !== 'Refusée' && (
|
||||
<button
|
||||
onClick={() => handleDeleteRequest(request.id)}
|
||||
className="text-orange-600 hover:text-orange-700 flex items-center gap-1 px-2 py-1 hover:bg-orange-50 rounded"
|
||||
title="Annuler"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Annuler</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bouton Voir */}
|
||||
<button
|
||||
onClick={() => handleViewRequest(request)}
|
||||
className="text-gray-600 hover:text-gray-700 flex items-center gap-1 px-2 py-1 hover:bg-gray-50 rounded"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Voir</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
{[...Array(totalPages)].map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => paginate(i + 1)}
|
||||
className={`px-3 py-1 rounded ${currentPage === i + 1
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white border border-gray-200 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: details */}
|
||||
<div className="hidden lg:block">
|
||||
{selectedRequest ? (
|
||||
<div className="bg-white rounded-xl shadow-md border border-gray-100 p-6 sticky top-20">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<h3 className="text-lg font-semibold">Détails de la demande</h3>
|
||||
<button onClick={handleCloseDetails} className="text-gray-500 hover:text-gray-800 p-2">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-sm text-gray-700">
|
||||
<div>
|
||||
<p className="text-gray-500">Type</p>
|
||||
<p className="text-base font-medium text-gray-900">{selectedRequest.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Dates</p>
|
||||
<p className="text-base font-medium text-gray-900">{selectedRequest.dateDisplay}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Nombre de jours</p>
|
||||
<p className="text-base font-medium text-gray-900">{selectedRequest.days}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Statut</p>
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${getStatusColor(selectedRequest.status)}`}>
|
||||
{selectedRequest.status}
|
||||
</span>
|
||||
</div>
|
||||
{selectedRequest.reason && (
|
||||
<div>
|
||||
<p className="text-gray-500">Motif</p>
|
||||
<p className="italic">{selectedRequest.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
{(selectedRequest.status === 'Validée' || selectedRequest.status === 'Refusée') && selectedRequest.validationComment && (
|
||||
<div>
|
||||
<p className="text-gray-500">{selectedRequest.status === 'Validée' ? 'Commentaire de validation' : 'Motif du refus'}</p>
|
||||
<p className="italic text-sm bg-gray-50 p-3 rounded-lg border-l-4" style={{ borderLeftColor: selectedRequest.status === 'Validée' ? '#10b981' : '#ef4444' }}>{selectedRequest.validationComment}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MedicalDocuments demandeId={selectedRequest.id} />
|
||||
</div>
|
||||
|
||||
{/* Actions dans la sidebar */}
|
||||
<div className="mt-6 pt-4 border-t space-y-2">
|
||||
{/* ⭐ Bouton Modifier - Toujours visible */}
|
||||
<button
|
||||
onClick={() => handleEditRequest(selectedRequest)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
Modifier cette demande
|
||||
</button>
|
||||
|
||||
{/* Bouton Annuler */}
|
||||
{selectedRequest.status !== 'Annulée' && selectedRequest.status !== 'Refusée' && (
|
||||
<button
|
||||
onClick={() => handleDeleteRequest(selectedRequest.id)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Annuler cette demande
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 text-sm text-gray-500">
|
||||
Sélectionnez une demande pour voir les détails
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showNewRequestModal && detailedCounters && (
|
||||
<NewLeaveRequestModal
|
||||
onClose={() => setShowNewRequestModal(false)}
|
||||
availableLeaveCounters={{
|
||||
availableCP_N: detailedCounters.cpN?.solde || 0,
|
||||
totalCP_N: detailedCounters.cpN?.acquis || 0,
|
||||
availableCP_N1: detailedCounters.cpN1?.solde || 0,
|
||||
availableRTT_N: detailedCounters.rttN?.solde || 0,
|
||||
totalRTT_N: detailedCounters.rttN?.acquis || 0,
|
||||
availableRTT_N1: 0,
|
||||
availableABS: 0,
|
||||
availableCP: (detailedCounters.cpN1?.solde || 0) + (detailedCounters.cpN?.solde || 0),
|
||||
availableRTT: detailedCounters.rttN?.solde || 0,
|
||||
availableRecup: detailedCounters?.recupN?.solde || 0,
|
||||
availableRecup_N: detailedCounters?.recupN?.solde || 0
|
||||
}}
|
||||
accessToken={graphToken}
|
||||
userId={userId}
|
||||
userEmail={user.email}
|
||||
userRole={user.role}
|
||||
userName={`${user.prenom} ${user.nom}`}
|
||||
onRequestSubmitted={() => {
|
||||
refreshAllData();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ⭐ MODAL D'ÉDITION CORRIGÉE */}
|
||||
{showEditRequestModal && requestToEdit && detailedCounters && (
|
||||
<EditLeaveRequestModal
|
||||
isOpen={showEditRequestModal}
|
||||
onClose={() => {
|
||||
console.log('❌ Fermeture du modal d\'édition');
|
||||
setShowEditRequestModal(false);
|
||||
setRequestToEdit(null);
|
||||
}}
|
||||
request={requestToEdit}
|
||||
availableLeaveCounters={{
|
||||
availableCP_N: detailedCounters.cpN?.solde || 0,
|
||||
totalCP_N: detailedCounters.cpN?.acquis || 0,
|
||||
availableCP_N1: detailedCounters.cpN1?.solde || 0,
|
||||
availableRTT_N: detailedCounters.rttN?.solde || 0,
|
||||
totalRTT_N: detailedCounters.rttN?.acquis || 0,
|
||||
availableRTT_N1: 0,
|
||||
availableABS: 0,
|
||||
availableCP: (detailedCounters.cpN1?.solde || 0) + (detailedCounters.cpN?.solde || 0),
|
||||
availableRTT: detailedCounters.rttN?.solde || 0,
|
||||
availableRecup: detailedCounters?.recupN?.solde || 0,
|
||||
availableRecup_N: detailedCounters?.recupN?.solde || 0
|
||||
}}
|
||||
accessToken={graphToken}
|
||||
userId={userId}
|
||||
userEmail={user.email}
|
||||
userName={`${user.prenom} ${user.nom}`}
|
||||
onRequestUpdated={() => {
|
||||
console.log('✅ Demande mise à jour, rafraîchissement...');
|
||||
refreshAllData();
|
||||
setShowEditRequestModal(false);
|
||||
setRequestToEdit(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Styles */}
|
||||
<style>{`
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Requests;
|
||||
7
GTA_P_V2/project/src/setupTests.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
115
GTA_P_V2/project/src/tests/Calendar.test.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from 'react';
|
||||
import { it, expect, vi, beforeEach, describe } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('../context/AuthContext', () => ({
|
||||
useAuth: vi.fn(() => ({
|
||||
user: {
|
||||
id: 1,
|
||||
role: 'collaborateur',
|
||||
email: 'test@example.com',
|
||||
prenom: 'John',
|
||||
nom: 'Doe'
|
||||
}
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('../components/Sidebar', () => ({
|
||||
default: () => <div data-testid="sidebar">Sidebar</div>
|
||||
}));
|
||||
|
||||
vi.mock('../components/NewLeaveRequestModal', () => ({
|
||||
default: ({ onClose }) => (
|
||||
<div data-testid="leave-modal">
|
||||
<div>Formulaire complet...</div>
|
||||
<button onClick={onClose}>Close Modal</button>
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
|
||||
import Calendar from '../pages/Calendar';
|
||||
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('Calendar', () => {
|
||||
beforeEach(() => {
|
||||
global.fetch.mockImplementation((url) => {
|
||||
if (url.includes('calendrier.api.gouv.fr')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({})
|
||||
});
|
||||
}
|
||||
if (url.includes('getTeamLeaves.php')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
leaves: [],
|
||||
filters: {}
|
||||
})
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({})
|
||||
});
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('ouvre le menu contextuel sur clic droit', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Calendar />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Attendre que le texte "Calendrier" soit rendu (max 30s)
|
||||
const calendrierElement = await screen.findByText('Calendrier', {}, { timeout: 30000 });
|
||||
|
||||
expect(calendrierElement).toBeTruthy();
|
||||
|
||||
// Pause courte pour que le calendrier se rende complètement
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const allDayElements = screen.getAllByText(/^\d+$/);
|
||||
|
||||
let selectedDay;
|
||||
for (const day of ['15', '16', '17', '18', '19', '20']) {
|
||||
try {
|
||||
selectedDay = screen.getByText(day);
|
||||
break;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!selectedDay && allDayElements.length > 0) {
|
||||
selectedDay = allDayElements[Math.floor(allDayElements.length / 2)];
|
||||
}
|
||||
|
||||
// Alternative à toBeInTheDocument
|
||||
expect(selectedDay).toBeTruthy();
|
||||
expect(document.body.contains(selectedDay)).toBe(true);
|
||||
|
||||
fireEvent.click(selectedDay);
|
||||
|
||||
// Attendre l’apparition de l’indication du jour sélectionné
|
||||
const selectedText = await screen.findByText(/Date sélectionnée|jour.*sélectionné/, {}, { timeout: 5000 });
|
||||
|
||||
expect(selectedText).toBeTruthy();
|
||||
|
||||
// Simuler clic droit
|
||||
fireEvent.contextMenu(selectedDay);
|
||||
|
||||
// Attendre l’apparition du menu contextuel (modal)
|
||||
const leaveModal = await screen.findByTestId('leave-modal', {}, { timeout: 5000 });
|
||||
|
||||
expect(leaveModal).toBeTruthy();
|
||||
expect(screen.getByText('Formulaire complet...')).toBeTruthy();
|
||||
}, 20000);
|
||||
});
|
||||
81
GTA_P_V2/project/src/tests/Login.test.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from 'react';
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import Login from '../pages/Login';
|
||||
|
||||
const mockLogin = vi.fn();
|
||||
const mockLoginWithO365 = vi.fn();
|
||||
|
||||
vi.mock('../context/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
login: mockLogin,
|
||||
loginWithO365: mockLoginWithO365,
|
||||
isAuthorized: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
describe('Login Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('affiche le bouton Office 365', () => {
|
||||
render(<MemoryRouter><Login /></MemoryRouter>);
|
||||
const o365Button = screen.getByRole('button', { name: /Se connecter avec Office 365/i });
|
||||
expect(o365Button).toBeTruthy();
|
||||
});
|
||||
|
||||
test('redirection après login O365 réussi', async () => {
|
||||
mockLoginWithO365.mockResolvedValue(true);
|
||||
render(<MemoryRouter><Login /></MemoryRouter>);
|
||||
const o365Button = screen.getByRole('button', { name: /Se connecter avec Office 365/i });
|
||||
fireEvent.click(o365Button);
|
||||
await waitFor(() => {
|
||||
expect(mockLoginWithO365).toHaveBeenCalled();
|
||||
// On teste uniquement la fonction, pas l'ouverture réelle de Microsoft
|
||||
});
|
||||
});
|
||||
|
||||
test('affiche une erreur si login O365 échoue', async () => {
|
||||
mockLoginWithO365.mockRejectedValue(new Error('Accès refusé'));
|
||||
render(<MemoryRouter><Login /></MemoryRouter>);
|
||||
const o365Button = screen.getByRole('button', { name: /Se connecter avec Office 365/i });
|
||||
fireEvent.click(o365Button);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Accès refusé/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('login classique réussi', async () => {
|
||||
mockLogin.mockResolvedValue(true);
|
||||
render(<MemoryRouter><Login /></MemoryRouter>);
|
||||
fireEvent.change(screen.getByPlaceholderText(/Email/i), { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(screen.getByPlaceholderText(/Mot de passe/i), { target: { value: 'password' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Se connecter/i }));
|
||||
await waitFor(() => {
|
||||
expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password');
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
test('login classique échoue', async () => {
|
||||
mockLogin.mockResolvedValue(false);
|
||||
render(<MemoryRouter><Login /></MemoryRouter>);
|
||||
fireEvent.change(screen.getByPlaceholderText(/Email/i), { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(screen.getByPlaceholderText(/Mot de passe/i), { target: { value: 'wrongpass' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Se connecter/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Identifiants incorrects/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
21
GTA_P_V2/project/src/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 3000
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom', 'react-router-dom']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
8
GTA_P_V2/project/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
BIN
GTA_P_V2/project/uploads/doc_6895edf8d6538.jpg
Normal file
|
After Width: | Height: | Size: 501 KiB |
BIN
GTA_P_V2/project/uploads/doc_6895efd7750ba.jpg
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
GTA_P_V2/project/uploads/doc_6895f5df5fa2c.jpg
Normal file
|
After Width: | Height: | Size: 501 KiB |
28
GTA_P_V2/project/vite.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://192.168.0.3:3004',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://192.168.0.3:3004',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/setupTests.js',
|
||||
},
|
||||
});
|
||||
10
GTA_P_V2/project/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
});
|
||||