Compare commits

...

27 Commits

Author SHA1 Message Date
9d24aff2e9 Merge branch 'master' of https://mygitea.ensup-adm.net/oimer/GTA 2026-01-12 12:17:23 +01:00
91cd1dff2f First_Commit 2026-01-12 12:16:53 +01:00
47c1fb99b8 Merge branch 'master' of https://mygitea.ensup-adm.net/oimer/GTA 2025-12-03 11:02:42 +01:00
048c2929b9 Reapply "V1_Fonctionnel_GTAV1_GTA"
This reverts commit 244db6bfb6.
2025-12-03 11:02:33 +01:00
89d74363f8 Reapply "V1_Fonctionnel_GTAV1_GTA"
This reverts commit 244db6bfb6.
2025-12-02 18:04:52 +01:00
6f75a66906 Revert "V1_GTA"
This reverts commit 881476122c.
2025-12-02 17:50:31 +01:00
0dc7125688 Merge branch 'master' of https://mygitea.ensup-adm.net/oimer/GTA 2025-12-02 17:50:17 +01:00
244db6bfb6 Revert "V1_Fonctionnel_GTAV1_GTA"
This reverts commit 6d244f5323.
2025-12-02 17:49:04 +01:00
6d244f5323 V1_Fonctionnel_GTAV1_GTA 2025-12-02 17:47:02 +01:00
881476122c V1_GTA 2025-11-28 16:55:45 +01:00
f22979a44a Reapply "V1_Sans_Congé_Anticipéfemini collaboratrice"
This reverts commit 7f15e380e3.
2025-11-17 10:39:27 +01:00
f3a3746c3e Revert "femini collaboratrice"
This reverts commit 148a02099f.
2025-11-17 10:38:50 +01:00
8b080b50df Merge branch 'master' of https://mygitea.ensup-adm.net/oimer/GTA 2025-11-17 10:35:05 +01:00
7f15e380e3 Revert "V1_Sans_Congé_Anticipéfemini collaboratrice"
This reverts commit 0eb4dbb99b.
2025-11-17 10:34:50 +01:00
0eb4dbb99b V1_Sans_Congé_Anticipéfemini collaboratrice 2025-11-17 10:31:53 +01:00
148a02099f femini collaboratrice 2025-08-29 15:30:31 +02:00
34a369dccd Les filtreCollaborateur case rh/president et directeur de campus 2025-08-29 15:12:21 +02:00
ac0ae03904 Vue global collaborateur pour manager 2025-08-28 11:59:58 +02:00
ed4a7c02ca changement au niveau de requetes adaptés aux collaborateurs AD 2025-08-27 09:40:17 +02:00
9fb0c0a27f ajout du dossier php pour mettre tout les fichiers php 2025-08-13 10:09:47 +02:00
e4eb8a945c TypeCongés 2025-08-12 17:26:18 +02:00
e1e4e81420 connexion avec o365 2025-08-12 16:14:44 +02:00
871f166457 modification concernant l'affichage de type de congés dans la pagedemande+Affichage du service du collabrateur et son rôle+ version mobile de la page demande 2025-08-11 17:19:49 +02:00
f5ee031efc revert b066dcd136
revert affichage du bloc détails de la demande
2025-08-11 14:55:59 +02:00
4a3cded08c Affichage de historique de demande avec toutes lesétats et les information nécessaire et la pièce jointe s'il est type"arretMaladie" 2025-08-08 16:54:35 +02:00
011620fb39 résumé de la demande 2025-08-08 14:37:37 +02:00
b066dcd136 affichage du bloc détails de la demande 2025-08-08 14:37:07 +02:00
63 changed files with 39073 additions and 4516 deletions

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
services:
backend:
image: ouijdaneim/gta-backend-dev:latest # ✅ Ajoute cette ligne
build:
context: ./project/public/Backend
dockerfile: DockerfileGTA.backend
container_name: gtaDev-backend
hostname: backend
ports:
- "8014:3004"
volumes:
- ./project/public/Backend/uploads:/app/uploads
networks:
- gtaDev-network
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
frontend:
image: ouijdaneim/gta-frontend-dev:latest # ✅ Ajoute cette ligne
build:
context: ./project
dockerfile: DockerfileGTA.frontend
container_name: gtaDev-frontend
hostname: frontend
ports:
- "3015:90"
environment:
- VITE_API_URL=http://backend:3004
networks:
- gtaDev-network
depends_on:
- backend
restart: unless-stopped
networks:
gtaDev-network:
driver: bridge

8068
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"devDependencies": {
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^5.0.2",
"jest": "^30.1.3",
"jsdom": "^26.1.0",
"nodemon": "^3.1.10",
"vitest": "^3.2.4"
},
"dependencies": {
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"express": "^5.1.0",
"framer-motion": "^12.23.22",
"node-cron": "^4.2.1",
"react-datepicker": "^9.1.0"
}
}

4145
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View 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"]

View 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

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<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>

6103
project/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,16 +9,31 @@
"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"
"vite": "^5.4.2",
"vitest": "^3.2.4"
}
}

View 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"]

View 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"
}
}

View 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);
});

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View 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]);
};

View 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'
};

View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -1,156 +0,0 @@
<?php
// Récupération des demandes en attente pour un manager
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
// Log des erreurs pour debug
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
error_log("Erreur connexion DB getPendingRequests: " . $conn->connect_error);
echo json_encode(["success" => false, "message" => "Erreur de connexion à la base de données"]);
exit();
}
$managerId = $_GET['manager_id'] ?? null;
if ($managerId === null) {
echo json_encode(["success" => false, "message" => "ID manager manquant"]);
exit();
}
error_log("getPendingRequests - Manager ID: $managerId");
// Fonction pour calculer les jours ouvrés
function getWorkingDays($startDate, $endDate) {
$workingDays = 0;
$current = new DateTime($startDate);
$end = new DateTime($endDate);
while ($current <= $end) {
$dayOfWeek = (int)$current->format('N');
if ($dayOfWeek < 6) {
$workingDays++;
}
$current->modify('+1 day');
}
return $workingDays;
}
try {
// D'abord, récupérer le service du manager
$queryManagerService = "SELECT ServiceId FROM Users WHERE ID = ?";
$stmtManager = $conn->prepare($queryManagerService);
$stmtManager->bind_param("i", $managerId);
$stmtManager->execute();
$resultManager = $stmtManager->get_result();
if ($managerRow = $resultManager->fetch_assoc()) {
$serviceId = $managerRow['ServiceId'];
error_log("getPendingRequests - Service ID du manager: $serviceId");
// Récupérer les demandes en attente de l'équipe
$queryRequests = "
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.EmployeeId,
CONCAT(u.Prenom, ' ', u.Nom) as employee_name,
u.Email as employee_email,
tc.Nom as type
FROM DemandeConge dc
JOIN Users u ON dc.EmployeeId = u.ID
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
WHERE u.ServiceId = ?
AND dc.Statut = 'En attente'
AND u.ID != ?
ORDER BY dc.DateDemande ASC
";
$stmtRequests = $conn->prepare($queryRequests);
$stmtRequests->bind_param("ii", $serviceId, $managerId);
$stmtRequests->execute();
$resultRequests = $stmtRequests->get_result();
$requests = [];
while ($row = $resultRequests->fetch_assoc()) {
$workingDays = getWorkingDays($row['DateDebut'], $row['DateFin']);
$startDate = new DateTime($row['DateDebut']);
$endDate = new DateTime($row['DateFin']);
$submittedDate = new DateTime($row['DateDemande']);
if ($row['DateDebut'] === $row['DateFin']) {
$dateDisplay = $startDate->format('d/m/Y');
} else {
$dateDisplay = $startDate->format('d/m/Y') . ' - ' . $endDate->format('d/m/Y');
}
$requests[] = [
'id' => (int)$row['Id'],
'employee_id' => (int)$row['EmployeeId'],
'employee_name' => $row['employee_name'],
'employee_email' => $row['employee_email'],
'type' => $row['type'],
'start_date' => $row['DateDebut'],
'end_date' => $row['DateFin'],
'date_display' => $dateDisplay,
'days' => $workingDays,
'status' => $row['Statut'],
'reason' => $row['Commentaire'] ?: '',
'submitted_at' => $row['DateDemande'],
'submitted_display' => $submittedDate->format('d/m/Y')
];
}
error_log("getPendingRequests - Demandes en attente trouvées: " . count($requests));
echo json_encode([
"success" => true,
"message" => "Demandes en attente récupérées avec succès",
"requests" => $requests,
"service_id" => $serviceId
]);
$stmtRequests->close();
} else {
error_log("getPendingRequests - Manager non trouvé: $managerId");
echo json_encode([
"success" => false,
"message" => "Manager non trouvé"
]);
}
$stmtManager->close();
} catch (Exception $e) {
error_log("Erreur getPendingRequests: " . $e->getMessage());
echo json_encode([
"success" => false,
"message" => "Erreur lors de la récupération des demandes: " . $e->getMessage()
]);
}
$conn->close();
?>

View File

@@ -1,278 +0,0 @@
<?php
// Récupération des compteurs de congés avec gestion des exercices
// Exercice CP: 01/06 au 31/05 | Exercice RTT: 01/01 au 31/12
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
// Gère la requête OPTIONS (pré-vol CORS)
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
// Log des erreurs pour debug
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
// IMPORTANT: Changer ces paramètres pour votre configuration locale
// $host = "localhost";
// $username = "root";
// $password = "";
// Crée une nouvelle connexion à la base de données
$conn = new mysqli($host, $username, $password, $dbname);
// Vérifie la connexion
if ($conn->connect_error) {
error_log("Erreur connexion DB getLeaveCounters: " . $conn->connect_error);
echo json_encode(["success" => false, "message" => "Erreur de connexion à la base de données : " . $conn->connect_error]);
exit();
}
// Récupère l'ID utilisateur depuis les paramètres de requête GET
$userId = $_GET['user_id'] ?? null;
error_log("=== DEBUT getLeaveCounters.php ===");
error_log("getLeaveCounters - user_id reçu: " . ($userId ?? 'NULL'));
error_log("getLeaveCounters - Toutes les variables GET: " . print_r($_GET, true));
if ($userId === null) {
error_log("getLeaveCounters - user_id manquant");
echo json_encode(["success" => false, "message" => "ID utilisateur manquant."]);
exit();
}
// Fonction pour déterminer l'exercice des congés payés (01/06 au 31/05)
function getLeaveYear($date = null) {
if ($date === null) {
$date = new DateTime();
} else {
$date = new DateTime($date);
}
$currentYear = (int)$date->format('Y');
$currentMonth = (int)$date->format('m');
// Si on est avant le 1er juin, l'exercice a commencé l'année précédente
if ($currentMonth < 6) {
return $currentYear - 1;
}
// Si on est le 1er juin ou après, l'exercice a commencé cette année
return $currentYear;
}
// Fonction pour déterminer l'année RTT (01/01 au 31/12)
function getRTTYear($date = null) {
if ($date === null) {
$date = new DateTime();
} else {
$date = new DateTime($date);
}
return (int)$date->format('Y');
}
// Récupère l'ID utilisateur depuis les paramètres de requête GET
$userId = $_GET['user_id'] ?? null;
if ($userId === null) {
echo json_encode(["success" => false, "message" => "ID utilisateur manquant."]);
exit();
}
// Calcul des exercices selon les règles de gestion
$leaveYear = getLeaveYear(); // Exercice CP (01/06 au 31/05)
$rttYear = getRTTYear(); // Exercice RTT (01/01 au 31/12)
$currentDate = date('Y-m-d'); // Date actuelle pour les filtres de demandes
// Variables pour les soldes disponibles
$cpSolde = 0;
$rttSolde = 0;
$absSolde = 0;
// Variables pour les demandes en cours/validées
$cpInProcess = 0;
$rttInProcess = 0;
$absenteism = 0;
// --- FONCTION UTILITAIRE POUR CALCULER LES JOURS OUVRÉS (hors week-ends) ---
function getWorkingDays($startDate, $endDate) {
$workingDays = 0;
$current = new DateTime($startDate);
$end = new DateTime($endDate);
while ($current <= $end) {
$dayOfWeek = (int)$current->format('N'); // 1 (pour Lundi) à 7 (pour Dimanche)
if ($dayOfWeek < 6) { // Si ce n'est ni Samedi (6) ni Dimanche (7)
$workingDays++;
}
$current->modify('+1 day');
}
return $workingDays;
}
// -------------------------------------------------------------------------
// --- Récupération du Solde de Congé Payé (CP) ---
$queryCPSolde = "SELECT cc.Solde FROM CompteurConges cc
JOIN TypeConge tc ON cc.TypeCongeId = tc.Id
WHERE cc.EmployeeId = ? AND tc.Nom = 'Congé payé' AND cc.Annee = ?";
$stmtCPSolde = $conn->prepare($queryCPSolde);
if ($stmtCPSolde === false) {
error_log("Erreur de préparation de la requête CP Solde : " . $conn->error);
} else {
$stmtCPSolde->bind_param("ii", $userId, $leaveYear);
$stmtCPSolde->execute();
$resultCPSolde = $stmtCPSolde->get_result();
if ($rowCPSolde = $resultCPSolde->fetch_assoc()) {
$cpSolde = $rowCPSolde['Solde'];
}
$stmtCPSolde->close();
}
// --- Récupération du Solde de RTT ---
$queryRTTSolde = "SELECT cc.Solde FROM CompteurConges cc
JOIN TypeConge tc ON tc.Id = cc.TypeCongeId
WHERE cc.EmployeeId = ? AND tc.Nom = 'RTT' AND cc.Annee = ?";
$stmtRTTSolde = $conn->prepare($queryRTTSolde);
if ($stmtRTTSolde === false) {
error_log("Erreur de préparation de la requête RTT Solde : " . $conn->error);
} else {
$stmtRTTSolde->bind_param("ii", $userId, $rttYear);
$stmtRTTSolde->execute();
$resultRTTSolde = $stmtRTTSolde->get_result();
if ($rowRTTSolde = $resultRTTSolde->fetch_assoc()) {
$rttSolde = $rowRTTSolde['Solde'];
}
$stmtRTTSolde->close();
}
// --- Récupération du Solde de Congé Maladie (ABS) ---
$queryABSSolde = "SELECT cc.Solde FROM CompteurConges cc
JOIN TypeConge tc ON tc.Id = cc.TypeCongeId
WHERE cc.EmployeeId = ? AND tc.Nom = 'Congé maladie' AND cc.Annee = ?";
$stmtABSSolde = $conn->prepare($queryABSSolde);
if ($stmtABSSolde === false) {
error_log("Erreur de préparation de la requête ABS Solde : " . $conn->error);
} else {
$stmtABSSolde->bind_param("ii", $userId, $rttYear);
$stmtABSSolde->execute();
$resultABSSolde = $stmtABSSolde->get_result();
if ($rowABSSolde = $resultABSSolde->fetch_assoc()) {
$absSolde = $rowABSSolde['Solde'];
}
$stmtABSSolde->close();
}
// --- Calcul des Congés Payés (CP) en cours (demandes 'En attente' ou 'Validée' dont la fin est >= date actuelle) ---
// Cette requête sélectionne les dates pour le calcul en PHP
$queryCPInProcessDates = "SELECT dc.DateDebut, dc.DateFin FROM DemandeConge dc
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
WHERE dc.EmployeeId = ?
AND tc.Nom = 'Congé payé'
AND dc.Statut IN ('En attente', 'Validée')
AND dc.DateFin >= ?";
$stmtCPInProcessDates = $conn->prepare($queryCPInProcessDates);
if ($stmtCPInProcessDates === false) {
error_log("Erreur de préparation de la requête CP en cours dates : " . $conn->error);
} else {
$stmtCPInProcessDates->bind_param("is", $userId, $currentDate);
$stmtCPInProcessDates->execute();
$resultCPInProcessDates = $stmtCPInProcessDates->get_result();
while ($row = $resultCPInProcessDates->fetch_assoc()) {
$cpInProcess += getWorkingDays($row['DateDebut'], $row['DateFin']);
}
$stmtCPInProcessDates->close();
}
// --- Calcul des RTT en cours (mêmes critères que CP, mais pour RTT) ---
$queryRTTInProcessDates = "SELECT dc.DateDebut, dc.DateFin FROM DemandeConge dc
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
WHERE dc.EmployeeId = ?
AND tc.Nom = 'RTT'
AND dc.Statut IN ('En attente', 'Validée')
AND dc.DateFin >= ?";
$stmtRTTInProcessDates = $conn->prepare($queryRTTInProcessDates);
if ($stmtRTTInProcessDates === false) {
error_log("Erreur de préparation de la requête RTT en cours dates : " . $conn->error);
} else {
$stmtRTTInProcessDates->bind_param("is", $userId, $currentDate);
$stmtRTTInProcessDates->execute();
$resultRTTInProcessDates = $stmtRTTInProcessDates->get_result();
while ($row = $resultRTTInProcessDates->fetch_assoc()) {
$rttInProcess += getWorkingDays($row['DateDebut'], $row['DateFin']);
}
$stmtRTTInProcessDates->close();
}
// --- Calcul des jours d'absence (ABS) (somme des jours DATEDIFF à partir de DemandeConge) ---
// Note: Ici, on ne modifie pas le calcul, car l'absentéisme maladie est souvent compté sur tous les jours, y compris week-ends, pour le suivi global.
// Si vous devez exclure les week-ends pour les ABS, appliquez getWorkingDays ici aussi.
$queryABSInProcess = "SELECT SUM(DATEDIFF(dc.DateFin, dc.DateDebut) + 1) AS total_abs FROM DemandeConge dc
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
WHERE dc.EmployeeId = ?
AND tc.Nom = 'Congé maladie'
AND dc.Statut = 'Validée'";
$stmtABSInProcess = $conn->prepare($queryABSInProcess);
if ($stmtABSInProcess === false) {
error_log("Erreur de préparation de la requête ABS en cours : " . $conn->error);
} else {
$stmtABSInProcess->bind_param("i", $userId);
$stmtABSInProcess->execute();
$resultABSInProcess = $stmtABSInProcess->get_result();
if ($rowABSInProcess = $resultABSInProcess->fetch_assoc()) {
$absenteism = $rowABSInProcess['total_abs'] ?? 0;
}
$stmtABSInProcess->close();
}
// --- Calcul des soldes disponibles réels (déduction "douce" pour l'affichage/validation frontend) ---
$availableCPCalculated = $cpSolde - $cpInProcess;
if ($availableCPCalculated < 0) {
$availableCPCalculated = 0;
}
$availableRTTCalculated = $rttSolde - $rttInProcess;
if ($availableRTTCalculated < 0) {
$availableRTTCalculated = 0;
}
// Renvoie les compteurs sous format JSON
echo json_encode([
"success" => true,
"message" => "Compteurs récupérés avec succès.",
"counters" => [
"availableCP" => (int)$availableCPCalculated, // CP: Solde brut - jours ouvrés en cours/validés futurs
"availableRTT" => (int)$availableRTTCalculated, // RTT: Solde brut - jours ouvrés en cours/validés futurs
"availableABS" => (int)$absSolde, // ABS: Solde brut (sans déduction des jours en cours)
"rttInProcess" => (int)$rttInProcess, // RTT: Jours ouvrés en attente/validés futurs (pour information)
"absenteism" => (int)$absenteism // ABS: Jours d'absence maladie validés/pris (pour information)
],
"debug_values" => [
"initial_cp_solde" => (int)$cpSolde,
"cp_en_cours" => (int)$cpInProcess,
"calculated_available_cp" => (int)$availableCPCalculated,
"initial_rtt_solde" => (int)$rttSolde,
"rtt_en_cours" => (int)$rttInProcess,
"calculated_available_rtt" => (int)$availableRTTCalculated,
"leave_year" => $leaveYear,
"rtt_year" => $rttYear,
"current_date_php" => $currentDate,
"user_id_php" => (int)$userId
]
]);
$conn->close();
?>

View File

@@ -1,156 +0,0 @@
<?php
// Récupération des demandes en attente pour un manager
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
// Log des erreurs pour debug
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
error_log("Erreur connexion DB getPendingRequests: " . $conn->connect_error);
echo json_encode(["success" => false, "message" => "Erreur de connexion à la base de données"]);
exit();
}
$managerId = $_GET['manager_id'] ?? null;
if ($managerId === null) {
echo json_encode(["success" => false, "message" => "ID manager manquant"]);
exit();
}
error_log("getPendingRequests - Manager ID: $managerId");
// Fonction pour calculer les jours ouvrés
function getWorkingDays($startDate, $endDate) {
$workingDays = 0;
$current = new DateTime($startDate);
$end = new DateTime($endDate);
while ($current <= $end) {
$dayOfWeek = (int)$current->format('N');
if ($dayOfWeek < 6) {
$workingDays++;
}
$current->modify('+1 day');
}
return $workingDays;
}
try {
// D'abord, récupérer le service du manager
$queryManagerService = "SELECT ServiceId FROM Users WHERE ID = ?";
$stmtManager = $conn->prepare($queryManagerService);
$stmtManager->bind_param("i", $managerId);
$stmtManager->execute();
$resultManager = $stmtManager->get_result();
if ($managerRow = $resultManager->fetch_assoc()) {
$serviceId = $managerRow['ServiceId'];
error_log("getPendingRequests - Service ID du manager: $serviceId");
// Récupérer les demandes en attente de l'équipe
$queryRequests = "
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.EmployeeId,
CONCAT(u.Prenom, ' ', u.Nom) as employee_name,
u.Email as employee_email,
tc.Nom as type
FROM DemandeConge dc
JOIN Users u ON dc.EmployeeId = u.ID
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
WHERE u.ServiceId = ?
AND dc.Statut = 'En attente'
AND u.ID != ?
ORDER BY dc.DateDemande ASC
";
$stmtRequests = $conn->prepare($queryRequests);
$stmtRequests->bind_param("ii", $serviceId, $managerId);
$stmtRequests->execute();
$resultRequests = $stmtRequests->get_result();
$requests = [];
while ($row = $resultRequests->fetch_assoc()) {
$workingDays = getWorkingDays($row['DateDebut'], $row['DateFin']);
$startDate = new DateTime($row['DateDebut']);
$endDate = new DateTime($row['DateFin']);
$submittedDate = new DateTime($row['DateDemande']);
if ($row['DateDebut'] === $row['DateFin']) {
$dateDisplay = $startDate->format('d/m/Y');
} else {
$dateDisplay = $startDate->format('d/m/Y') . ' - ' . $endDate->format('d/m/Y');
}
$requests[] = [
'id' => (int)$row['Id'],
'employee_id' => (int)$row['EmployeeId'],
'employee_name' => $row['employee_name'],
'employee_email' => $row['employee_email'],
'type' => $row['type'],
'start_date' => $row['DateDebut'],
'end_date' => $row['DateFin'],
'date_display' => $dateDisplay,
'days' => $workingDays,
'status' => $row['Statut'],
'reason' => $row['Commentaire'] ?: '',
'submitted_at' => $row['DateDemande'],
'submitted_display' => $submittedDate->format('d/m/Y')
];
}
error_log("getPendingRequests - Demandes en attente trouvées: " . count($requests));
echo json_encode([
"success" => true,
"message" => "Demandes en attente récupérées avec succès",
"requests" => $requests,
"service_id" => $serviceId
]);
$stmtRequests->close();
} else {
error_log("getPendingRequests - Manager non trouvé: $managerId");
echo json_encode([
"success" => false,
"message" => "Manager non trouvé"
]);
}
$stmtManager->close();
} catch (Exception $e) {
error_log("Erreur getPendingRequests: " . $e->getMessage());
echo json_encode([
"success" => false,
"message" => "Erreur lors de la récupération des demandes: " . $e->getMessage()
]);
}
$conn->close();
?>

View File

@@ -1,195 +0,0 @@
<?php
// Récupération des demandes de congés avec gestion des exercices
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
// Gère la requête OPTIONS (pré-vol CORS)
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
// Log des erreurs pour debug
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
// Crée une nouvelle connexion à la base de données
$conn = new mysqli($host, $username, $password, $dbname);
// Vérifie la connexion
if ($conn->connect_error) {
error_log("Erreur connexion DB getRequests: " . $conn->connect_error);
echo json_encode(["success" => false, "message" => "Erreur de connexion à la base de données : " . $conn->connect_error]);
exit();
}
// Récupère l'ID utilisateur depuis les paramètres de requête GET
$userId = $_GET['user_id'] ?? null;
error_log("=== DEBUT getRequests.php ===");
error_log("getRequests - user_id reçu: " . ($userId ?? 'NULL'));
error_log("getRequests - Toutes les variables GET: " . print_r($_GET, true));
if ($userId === null) {
error_log("getRequests - user_id manquant");
echo json_encode(["success" => false, "message" => "ID utilisateur manquant."]);
exit();
}
error_log("getRequests - Récupération pour user_id: $userId (type: " . gettype($userId) . ")");
// Vérifier si l'utilisateur existe
$checkUserQuery = "SELECT ID, Nom, Prenom FROM Users WHERE ID = ?";
$checkUserStmt = $conn->prepare($checkUserQuery);
if ($checkUserStmt) {
$checkUserStmt->bind_param("i", $userId);
$checkUserStmt->execute();
$userResult = $checkUserStmt->get_result();
if ($userRow = $userResult->fetch_assoc()) {
error_log("getRequests - Utilisateur trouvé: " . $userRow['Prenom'] . " " . $userRow['Nom']);
} else {
error_log("getRequests - ATTENTION: Utilisateur ID $userId non trouvé dans la table Users");
}
$checkUserStmt->close();
}
// Fonction pour calculer les jours ouvrés (hors week-ends)
function getWorkingDays($startDate, $endDate) {
$workingDays = 0;
$current = new DateTime($startDate);
$end = new DateTime($endDate);
while ($current <= $end) {
$dayOfWeek = (int)$current->format('N'); // 1 (Lundi) à 7 (Dimanche)
if ($dayOfWeek < 6) { // Si ce n'est ni Samedi (6) ni Dimanche (7)
$workingDays++;
}
$current->modify('+1 day');
}
return $workingDays;
}
try {
// Requête pour récupérer les demandes de l'utilisateur avec les informations du type de congé
$query = "
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.Validateur,
tc.Nom as TypeConge
FROM DemandeConge dc
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
WHERE dc.EmployeeId = ?
ORDER BY dc.DateDemande DESC
";
error_log("getRequests - Requête SQL: $query");
$stmt = $conn->prepare($query);
if ($stmt === false) {
throw new Exception("Erreur de préparation de la requête : " . $conn->error);
}
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
error_log("getRequests - Nombre de résultats trouvés: " . $result->num_rows);
// Debug: Afficher toutes les demandes de la table pour cet utilisateur
$debugQuery = "SELECT COUNT(*) as total FROM DemandeConge WHERE EmployeeId = ?";
$debugStmt = $conn->prepare($debugQuery);
if ($debugStmt) {
$debugStmt->bind_param("i", $userId);
$debugStmt->execute();
$debugResult = $debugStmt->get_result();
$debugRow = $debugResult->fetch_assoc();
error_log("getRequests - Total demandes en DB pour user $userId: " . $debugRow['total']);
$debugStmt->close();
}
$requests = [];
while ($row = $result->fetch_assoc()) {
error_log("getRequests - Traitement demande ID: " . $row['Id']);
// Calcul des jours ouvrés
$workingDays = getWorkingDays($row['DateDebut'], $row['DateFin']);
// Mapping des types de congés pour l'affichage
$displayType = $row['TypeConge'];
switch ($row['TypeConge']) {
case 'Congé payé':
$displayType = 'Congés payés';
break;
case 'RTT':
$displayType = 'RTT';
break;
case 'Congé maladie':
$displayType = 'Congé maladie';
break;
}
// Formatage des dates pour l'affichage
$startDate = new DateTime($row['DateDebut']);
$endDate = new DateTime($row['DateFin']);
$submittedDate = new DateTime($row['DateDemande']);
// Format d'affichage des dates
if ($row['DateDebut'] === $row['DateFin']) {
$dateDisplay = $startDate->format('d/m/Y');
} else {
$dateDisplay = $startDate->format('d/m/Y') . ' - ' . $endDate->format('d/m/Y');
}
$requests[] = [
'id' => (int)$row['Id'],
'type' => $displayType,
'startDate' => $row['DateDebut'],
'endDate' => $row['DateFin'],
'dateDisplay' => $dateDisplay,
'days' => $workingDays,
'status' => $row['Statut'],
'reason' => $row['Commentaire'] ?: 'Aucun commentaire',
'submittedAt' => $row['DateDemande'],
'submittedDisplay' => $submittedDate->format('d/m/Y'),
'validator' => $row['Validateur'] ?: null
];
}
$stmt->close();
error_log("getRequests - Demandes formatées: " . count($requests));
error_log("getRequests - Détail des demandes: " . print_r($requests, true));
error_log("=== FIN getRequests.php ===");
echo json_encode([
"success" => true,
"message" => "Demandes récupérées avec succès.",
"requests" => $requests,
"total" => count($requests)
]);
} catch (Exception $e) {
error_log("Erreur récupération demandes : " . $e->getMessage());
echo json_encode([
"success" => false,
"message" => "Erreur lors de la récupération des demandes : " . $e->getMessage()
]);
}
$conn->close();
?>

View File

@@ -1,115 +0,0 @@
<?php
// Récupération des congés de l'équipe pour affichage dans le calendrier
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
// Log des erreurs pour debug
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
error_log("Erreur connexion DB getTeamLeaves: " . $conn->connect_error);
echo json_encode(["success" => false, "message" => "Erreur de connexion à la base de données"]);
exit();
}
$userId = $_GET['user_id'] ?? null;
if ($userId === null) {
echo json_encode(["success" => false, "message" => "ID utilisateur manquant"]);
exit();
}
error_log("getTeamLeaves - User ID: $userId");
try {
// Récupérer le service de l'utilisateur
$queryUserService = "SELECT ServiceId FROM Users WHERE ID = ?";
$stmtUser = $conn->prepare($queryUserService);
$stmtUser->bind_param("i", $userId);
$stmtUser->execute();
$resultUser = $stmtUser->get_result();
if ($userRow = $resultUser->fetch_assoc()) {
$serviceId = $userRow['ServiceId'];
error_log("getTeamLeaves - Service ID: $serviceId");
// Récupérer les congés validés de l'équipe (même service)
$queryLeaves = "
SELECT
dc.DateDebut as start_date,
dc.DateFin as end_date,
CONCAT(u.Prenom, ' ', u.Nom) as employee_name,
tc.Nom as type,
tc.CouleurHex as color
FROM DemandeConge dc
JOIN Users u ON dc.EmployeeId = u.ID
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
WHERE u.ServiceId = ?
AND dc.Statut = 'Validée'
AND dc.DateFin >= CURDATE() - INTERVAL 30 DAY
ORDER BY dc.DateDebut ASC
";
$stmtLeaves = $conn->prepare($queryLeaves);
$stmtLeaves->bind_param("i", $serviceId);
$stmtLeaves->execute();
$resultLeaves = $stmtLeaves->get_result();
$leaves = [];
while ($row = $resultLeaves->fetch_assoc()) {
$leaves[] = [
'start_date' => $row['start_date'],
'end_date' => $row['end_date'],
'employee_name' => $row['employee_name'],
'type' => $row['type'],
'color' => $row['color'] ?? '#3B82F6'
];
}
error_log("getTeamLeaves - Congés trouvés: " . count($leaves));
echo json_encode([
"success" => true,
"message" => "Congés de l'équipe récupérés avec succès",
"leaves" => $leaves,
"service_id" => $serviceId
]);
$stmtLeaves->close();
} else {
error_log("getTeamLeaves - Utilisateur non trouvé: $userId");
echo json_encode([
"success" => false,
"message" => "Utilisateur non trouvé"
]);
}
$stmtUser->close();
} catch (Exception $e) {
error_log("Erreur getTeamLeaves: " . $e->getMessage());
echo json_encode([
"success" => false,
"message" => "Erreur lors de la récupération des congés: " . $e->getMessage()
]);
}
$conn->close();
?>

View File

@@ -1,116 +0,0 @@
<?php
// Récupération des membres de l'équipe pour un manager
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
// Log des erreurs pour debug
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
error_log("Erreur connexion DB getTeamMembers: " . $conn->connect_error);
echo json_encode(["success" => false, "message" => "Erreur de connexion à la base de données"]);
exit();
}
$managerId = $_GET['manager_id'] ?? null;
if ($managerId === null) {
echo json_encode(["success" => false, "message" => "ID manager manquant"]);
exit();
}
error_log("getTeamMembers - Manager ID: $managerId");
try {
// D'abord, récupérer le service du manager
$queryManagerService = "SELECT ServiceId FROM Users WHERE ID = ?";
$stmtManager = $conn->prepare($queryManagerService);
$stmtManager->bind_param("i", $managerId);
$stmtManager->execute();
$resultManager = $stmtManager->get_result();
if ($managerRow = $resultManager->fetch_assoc()) {
$serviceId = $managerRow['ServiceId'];
error_log("getTeamMembers - Service ID du manager: $serviceId");
// Récupérer tous les membres du même service (sauf le manager lui-même)
$queryTeam = "
SELECT
u.ID as id,
u.Nom as nom,
u.Prenom as prenom,
u.Email as email,
u.Role as role,
u.DateEmbauche as date_embauche,
s.Nom as service_name
FROM Users u
JOIN Services s ON u.ServiceId = s.Id
WHERE u.ServiceId = ? AND u.ID != ? AND u.Actif = 1
ORDER BY u.Prenom, u.Nom
";
$stmtTeam = $conn->prepare($queryTeam);
$stmtTeam->bind_param("ii", $serviceId, $managerId);
$stmtTeam->execute();
$resultTeam = $stmtTeam->get_result();
$teamMembers = [];
while ($row = $resultTeam->fetch_assoc()) {
$teamMembers[] = [
'id' => (int)$row['id'],
'nom' => $row['nom'],
'prenom' => $row['prenom'],
'email' => $row['email'],
'role' => $row['role'],
'date_embauche' => $row['date_embauche'],
'service_name' => $row['service_name']
];
}
error_log("getTeamMembers - Membres trouvés: " . count($teamMembers));
echo json_encode([
"success" => true,
"message" => "Équipe récupérée avec succès",
"team_members" => $teamMembers,
"service_id" => $serviceId
]);
$stmtTeam->close();
} else {
error_log("getTeamMembers - Manager non trouvé: $managerId");
echo json_encode([
"success" => false,
"message" => "Manager non trouvé"
]);
}
$stmtManager->close();
} catch (Exception $e) {
error_log("Erreur getTeamMembers: " . $e->getMessage());
echo json_encode([
"success" => false,
"message" => "Erreur lors de la récupération de l'équipe: " . $e->getMessage()
]);
}
$conn->close();
?>

View File

@@ -1,74 +0,0 @@
<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
// Gère la requête OPTIONS (pré-vol CORS)
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
// Crée une nouvelle connexion à la base de données
$conn = new mysqli($host, $username, $password, $dbname);
// Vérifie la connexion
if ($conn->connect_error) {
// En cas d'erreur de connexion, renvoie un JSON d'échec
die(json_encode(["success" => false, "message" => "Erreur de connexion à la base de données : " . $conn->connect_error]));
}
// Récupère les données JSON envoyées via la requête POST
$data = json_decode(file_get_contents('php://input'), true);
$email = $data['email'] ?? '';
$mot_de_passe = $data['mot_de_passe'] ?? '';
$query = "SELECT ID, Prenom, Nom, Email, Role FROM Users WHERE Email = ? AND MDP = ?";
$stmt = $conn->prepare($query);
// Vérifie si la préparation de la requête a réussi
if ($stmt === false) {
die(json_encode(["success" => false, "message" => "Erreur de préparation de la requête : " . $conn->error]));
}
// Lie les paramètres (ss = string, string pour email et mot_de_passe)
$stmt->bind_param("ss", $email, $mot_de_passe);
$stmt->execute();
// Récupère le résultat de la requête
$result = $stmt->get_result();
// Vérifie si un utilisateur correspondant a été trouvé
if ($result->num_rows === 1) {
// Récupère la ligne de l'utilisateur sous forme de tableau associatif
$user = $result->fetch_assoc();
// Renvoie une réponse JSON de succès avec les données de l'utilisateur
echo json_encode([
"success" => true,
"message" => "Connexion réussie.",
"user" => [
"id" => $user['ID'],
"prenom" => $user['Prenom'],
"nom" => $user['Nom'],
"email" => $user['Email'],
"role" => $user['Role']
]
]);
} else {
// Renvoie une réponse JSON d'échec si les identifiants sont incorrects
echo json_encode(["success" => false, "message" => "Identifiants incorrects."]);
}
// Ferme la connexion à la base de données
$stmt->close();
$conn->close();
?>

View File

@@ -1,116 +0,0 @@
<?php
// Script manuel pour réinitialiser les compteurs
// Accès direct via navigateur pour les administrateurs
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Réinitialisation des Compteurs</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
.container { background: #f5f5f5; padding: 30px; border-radius: 10px; }
.warning { background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0; }
.success { background: #d4edda; border: 1px solid #c3e6cb; padding: 15px; border-radius: 5px; margin: 20px 0; }
.error { background: #f8d7da; border: 1px solid #f5c6cb; padding: 15px; border-radius: 5px; margin: 20px 0; }
button { background: #007bff; color: white; padding: 12px 24px; border: none; border-radius: 5px; cursor: pointer; font-size: 16px; }
button:hover { background: #0056b3; }
.danger { background: #dc3545; }
.danger:hover { background: #c82333; }
pre { background: #f8f9fa; padding: 15px; border-radius: 5px; overflow-x: auto; }
</style>
</head>
<body>
<div class="container">
<h1>🔄 Réinitialisation des Compteurs de Congés</h1>
<div class="warning">
<h3>⚠️ ATTENTION</h3>
<p>Cette opération va réinitialiser TOUS les compteurs de congés selon les règles suivantes :</p>
<ul>
<li><strong>Congés Payés :</strong> 25 jours (exercice du 01/06 au 31/05)</li>
<li><strong>RTT :</strong> 10 jours pour 2025 (exercice du 01/01 au 31/12)</li>
<li><strong>Congés Maladie :</strong> 0 jours (remise à zéro)</li>
</ul>
<p><strong>Cette action est irréversible !</strong></p>
</div>
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['confirm_reset'])) {
// Appel du script de réinitialisation
$resetUrl = 'http://localhost/project/public/resetLeaveCounters.php';
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/json',
'content' => json_encode(['manual_reset' => true])
]
]);
$result = file_get_contents($resetUrl, false, $context);
$data = json_decode($result, true);
if ($data && $data['success']) {
echo '<div class="success">';
echo '<h3>✅ Réinitialisation réussie !</h3>';
echo '<p>Employés mis à jour : ' . $data['details']['employees_updated'] . '</p>';
echo '<p>Exercice CP : ' . $data['details']['leave_year'] . '</p>';
echo '<p>Année RTT : ' . $data['details']['rtt_year'] . '</p>';
echo '<p>Date de réinitialisation : ' . $data['details']['reset_date'] . '</p>';
if (!empty($data['log'])) {
echo '<details><summary>Voir le détail</summary><pre>';
foreach ($data['log'] as $logLine) {
echo htmlspecialchars($logLine) . "\n";
}
echo '</pre></details>';
}
echo '</div>';
} else {
echo '<div class="error">';
echo '<h3>❌ Erreur lors de la réinitialisation</h3>';
echo '<p>' . ($data['message'] ?? 'Erreur inconnue') . '</p>';
echo '</div>';
}
}
?>
<form method="POST" onsubmit="return confirm('Êtes-vous sûr de vouloir réinitialiser TOUS les compteurs ? Cette action est irréversible.');">
<p>
<label>
<input type="checkbox" name="confirm_reset" value="1" required>
Je confirme vouloir réinitialiser tous les compteurs de congés
</label>
</p>
<button type="submit" class="danger">🔄 RÉINITIALISER LES COMPTEURS</button>
</form>
<hr style="margin: 40px 0;">
<h3>📋 Informations sur les exercices</h3>
<?php
$currentDate = new DateTime();
$currentYear = (int)$currentDate->format('Y');
$currentMonth = (int)$currentDate->format('m');
// Calcul exercice CP
$leaveYear = ($currentMonth < 6) ? $currentYear - 1 : $currentYear;
$leaveYearEnd = $leaveYear + 1;
echo "<p><strong>Exercice Congés Payés actuel :</strong> du 01/06/$leaveYear au 31/05/$leaveYearEnd</p>";
echo "<p><strong>Exercice RTT actuel :</strong> du 01/01/$currentYear au 31/12/$currentYear</p>";
echo "<p><strong>Date actuelle :</strong> " . $currentDate->format('d/m/Y H:i:s') . "</p>";
?>
<h3>🔗 Actions rapides</h3>
<p>
<a href="getLeaveCounters.php?user_id=1" target="_blank">
<button type="button">Voir les compteurs (User ID 1)</button>
</a>
</p>
</div>
</body>
</html>

View File

@@ -1,228 +0,0 @@
<?php
// Script de réinitialisation des compteurs de congés
// À exécuter manuellement ou via cron job
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
header("Content-Type: application/json");
// Gère la requête OPTIONS (pré-vol CORS)
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
// Log des erreurs pour debug
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
// Connexion à la base de données
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
error_log("Erreur connexion DB reset: " . $conn->connect_error);
echo json_encode([
"success" => false,
"message" => "Erreur de connexion à la base de données : " . $conn->connect_error
]);
exit();
}
// Log de debug
error_log("Reset counters - Début du script");
// Fonction pour déterminer l'exercice des congés payés (01/06 au 31/05)
function getLeaveYear($date = null) {
if ($date === null) {
$date = new DateTime();
} else {
$date = new DateTime($date);
}
$currentYear = (int)$date->format('Y');
$currentMonth = (int)$date->format('m');
// Si on est avant le 1er juin, l'exercice a commencé l'année précédente
if ($currentMonth < 6) {
return $currentYear - 1;
}
// Si on est le 1er juin ou après, l'exercice a commencé cette année
return $currentYear;
}
// Fonction pour déterminer l'année RTT (01/01 au 31/12)
function getRTTYear($date = null) {
if ($date === null) {
$date = new DateTime();
} else {
$date = new DateTime($date);
}
return (int)$date->format('Y');
}
try {
$conn->begin_transaction();
$currentDate = new DateTime();
$leaveYear = getLeaveYear();
$rttYear = getRTTYear();
error_log("Reset counters - Exercice CP: $leaveYear, RTT: $rttYear");
$resetLog = [];
// 1. Récupérer tous les employés depuis la table Users
$queryEmployees = "SELECT ID FROM Users";
$resultEmployees = $conn->query($queryEmployees);
if (!$resultEmployees) {
throw new Exception("Erreur lors de la récupération des employés : " . $conn->error);
}
error_log("Reset counters - Nombre d'employés trouvés: " . $resultEmployees->num_rows);
// 2. Récupérer les IDs des types de congés
$queryTypes = "SELECT Id, Nom FROM TypeConge WHERE Nom IN ('Congé payé', 'RTT', 'Congé maladie')";
$resultTypes = $conn->query($queryTypes);
$typeIds = [];
while ($row = $resultTypes->fetch_assoc()) {
$typeIds[$row['Nom']] = $row['Id'];
}
error_log("Reset counters - Types trouvés: " . print_r($typeIds, true));
if (count($typeIds) < 3) {
throw new Exception("Types de congés manquants dans la base de données");
}
// 3. Pour chaque employé, réinitialiser les compteurs
$employeesUpdated = 0;
while ($employee = $resultEmployees->fetch_assoc()) {
$employeeId = $employee['ID'];
error_log("Reset counters - Traitement employé: $employeeId");
// CONGÉS PAYÉS - Exercice du 01/06 au 31/05 (25 jours)
$queryUpdateCP = "
INSERT INTO CompteurConges (EmployeeId, TypeCongeId, Annee, Solde, Total)
VALUES (?, ?, ?, 25, 25)
ON DUPLICATE KEY UPDATE
Solde = 25,
Total = 25
";
$stmtCP = $conn->prepare($queryUpdateCP);
if (!$stmtCP) {
throw new Exception("Erreur préparation CP: " . $conn->error);
}
$stmtCP->bind_param("iii", $employeeId, $typeIds['Congé payé'], $leaveYear);
if (!$stmtCP->execute()) {
throw new Exception("Erreur lors de la mise à jour des CP pour l'employé $employeeId : " . $stmtCP->error);
}
$stmtCP->close();
// RTT - Année civile du 01/01 au 31/12
// Calcul du nombre de RTT selon l'année
$rttCount = 10; // Par défaut 10 pour 2025
if ($rttYear == 2024) {
$rttCount = 8; // Exemple pour 2024
} elseif ($rttYear >= 2025) {
$rttCount = 10; // 10 pour 2025 et après
}
$queryUpdateRTT = "
INSERT INTO CompteurConges (EmployeeId, TypeCongeId, Annee, Solde, Total)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
Solde = ?,
Total = ?
";
$stmtRTT = $conn->prepare($queryUpdateRTT);
if (!$stmtRTT) {
throw new Exception("Erreur préparation RTT: " . $conn->error);
}
$stmtRTT->bind_param("iiiiiii", $employeeId, $typeIds['RTT'], $rttYear, $rttCount, $rttCount, $rttCount, $rttCount);
if (!$stmtRTT->execute()) {
throw new Exception("Erreur lors de la mise à jour des RTT pour l'employé $employeeId : " . $stmtRTT->error);
}
$stmtRTT->close();
// CONGÉ MALADIE - Réinitialiser à 0 (pas de limite)
$queryUpdateABS = "
INSERT INTO CompteurConges (EmployeeId, TypeCongeId, Annee, Solde, Total)
VALUES (?, ?, ?, 0, 0)
ON DUPLICATE KEY UPDATE
Solde = 0,
Total = 0
";
$stmtABS = $conn->prepare($queryUpdateABS);
if (!$stmtABS) {
throw new Exception("Erreur préparation ABS: " . $conn->error);
}
$stmtABS->bind_param("iii", $employeeId, $typeIds['Congé maladie'], $rttYear);
if (!$stmtABS->execute()) {
throw new Exception("Erreur lors de la mise à jour des ABS pour l'employé $employeeId : " . $stmtABS->error);
}
$stmtABS->close();
$resetLog[] = "Employé $employeeId : CP=$leaveYear (25j), RTT=$rttYear ({$rttCount}j), ABS=$rttYear (0j)";
$employeesUpdated++;
}
error_log("Reset counters - Employés mis à jour: $employeesUpdated");
// 4. Log de la réinitialisation
$logEntry = "
=== RÉINITIALISATION DES COMPTEURS ===
Date: " . $currentDate->format('Y-m-d H:i:s') . "
Exercice CP: $leaveYear (01/06/$leaveYear au 31/05/" . ($leaveYear + 1) . ")
Année RTT: $rttYear (01/01/$rttYear au 31/12/$rttYear)
Employés traités: $employeesUpdated
Détails:
" . implode("\n ", $resetLog) . "
";
// Sauvegarder le log (optionnel - créer une table de logs si nécessaire)
error_log($logEntry, 3, "reset_counters.log");
$conn->commit();
error_log("Reset counters - Transaction commitée avec succès");
echo json_encode([
"success" => true,
"message" => "Compteurs réinitialisés avec succès",
"details" => [
"employees_updated" => $employeesUpdated,
"leave_year" => $leaveYear,
"rtt_year" => $rttYear,
"cp_days" => 25,
"rtt_days" => $rttCount,
"reset_date" => $currentDate->format('Y-m-d H:i:s')
],
"log" => $resetLog
]);
} catch (Exception $e) {
$conn->rollback();
error_log("Erreur réinitialisation compteurs : " . $e->getMessage());
echo json_encode([
"success" => false,
"message" => "Erreur lors de la réinitialisation : " . $e->getMessage()
]);
}
$conn->close();
?>

View File

@@ -1,181 +0,0 @@
<?php
// Active l'affichage des erreurs pour le dev
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
// Gère le pré-vol CORS
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
// --- Paramètres de connexion ---
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
// Connexion
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
error_log("Erreur connexion DB submitLeaveRequest: " . $conn->connect_error);
echo json_encode([
"success" => false,
"message" => "Erreur de connexion DB : " . $conn->connect_error
]);
exit();
}
// Lecture du JSON envoyé
$input = file_get_contents('php://input');
error_log("submitLeaveRequest - Input reçu: " . $input);
$data = json_decode($input, true);
if (!isset(
$data['EmployeeId'],
$data['TypeConge'],
$data['DateDebut'],
$data['DateFin'],
$data['NumDays']
)) {
error_log("submitLeaveRequest - Données manquantes: " . print_r($data, true));
echo json_encode([
"success" => false,
"message" => "Données manquantes pour la demande de congé."
]);
exit();
}
// Récupération des champs
$employeeId = (int) $data['EmployeeId'];
$typeCongeNom= $data['TypeConge'];
$dateDebut = $data['DateDebut'];
$dateFin = $data['DateFin'];
$commentaire = $data['Commentaire'] ?? '';
$numDays = (int) $data['NumDays'];
error_log("submitLeaveRequest - Données parsées: EmployeeId=$employeeId, Type=$typeCongeNom, Début=$dateDebut, Fin=$dateFin");
$statut = 'En attente';
$validateur = null;
$currentDate= date('Y-m-d H:i:s'); // date complète pour DateDemande
// Mapping frontend → DB
switch ($typeCongeNom) {
case 'CP': $dbTypeCongeName = 'Congé payé'; break;
case 'RTT': $dbTypeCongeName = 'RTT'; break;
case 'ABS': $dbTypeCongeName = 'Congé maladie'; break;
default:
error_log("submitLeaveRequest - Type de congé inconnu: $typeCongeNom");
echo json_encode([
"success" => false,
"message" => "Type de congé inconnu."
]);
$conn->close();
exit();
}
error_log("submitLeaveRequest - Type DB mappé: $dbTypeCongeName");
// Récupération de l'ID du type de congé
$stmt = $conn->prepare("SELECT Id FROM TypeConge WHERE Nom = ?");
if (!$stmt) {
error_log("submitLeaveRequest - Erreur préparation requête TypeConge: " . $conn->error);
echo json_encode([
"success" => false,
"message" => "Erreur préparation requête TypeConge"
]);
$conn->close();
exit();
}
$stmt->bind_param("s", $dbTypeCongeName);
$stmt->execute();
$res = $stmt->get_result();
if ($row = $res->fetch_assoc()) {
$typeCongeId = (int) $row['Id'];
error_log("submitLeaveRequest - TypeCongeId trouvé: $typeCongeId");
} else {
error_log("submitLeaveRequest - Type de congé non trouvé en DB: $dbTypeCongeName");
echo json_encode([
"success" => false,
"message" => "Type de congé non trouvé en DB : $dbTypeCongeName"
]);
$stmt->close();
$conn->close();
exit();
}
$stmt->close();
// Requête d'insertion dans DemandeConge
$query = "
INSERT INTO DemandeConge
(EmployeeId, DateDebut, DateFin, TypeCongeId, Statut, DateDemande, Commentaire, Validateur, NombreJours)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?)
";
error_log("submitLeaveRequest - Requête d'insertion: $query");
// Préparation de la requête
$stmt = $conn->prepare($query);
if (!$stmt) {
error_log("Erreur prepare insert : " . $conn->error);
echo json_encode([
"success" => false,
"message" => "Erreur interne lors de la préparation de la requête."
]);
$conn->close();
exit();
}
// Pour la colonne Validateur, on passe '' si null
$validParam = $validateur ?? '';
error_log("submitLeaveRequest - Paramètres bind: $employeeId, $dateDebut, $dateFin, $typeCongeId, $statut, $currentDate, $commentaire, $validParam, $numDays");
// Bind des paramètres (types : i=integer, s=string, d=decimal)
$stmt->bind_param(
"ississssi",
$employeeId, // i
$dateDebut, // s
$dateFin, // s
$typeCongeId, // i
$statut, // s
$currentDate, // s - DateDemande
$commentaire, // s
$validParam, // s
$numDays // i - NombreJours
);
// Exécution
if ($stmt->execute()) {
$insertId = $conn->insert_id;
error_log("submitLeaveRequest - Insertion réussie, ID: $insertId");
echo json_encode([
"success" => true,
"message" => "Demande de congé soumise avec succès.",
"request_id" => $insertId
]);
} else {
error_log("Erreur execute insert : " . $stmt->error);
echo json_encode([
"success" => false,
"message" => "Erreur lors de l'enregistrement : " . $stmt->error
]);
}
$stmt->close();
$conn->close();
error_log("submitLeaveRequest - Script terminé");
?>

View File

@@ -1,14 +0,0 @@
<?php
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
die("❌ Connexion échouée : " . $conn->connect_error);
}
echo "✅ Connexion réussie à la base de données !";
?>

View File

@@ -1,197 +0,0 @@
<?php
// Validation/Refus d'une demande de congé par un manager
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
// Log des erreurs pour debug
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
error_log("Erreur connexion DB validateRequest: " . $conn->connect_error);
echo json_encode(["success" => false, "message" => "Erreur de connexion à la base de données"]);
exit();
}
// Lecture du JSON envoyé
$input = file_get_contents('php://input');
error_log("validateRequest - Input reçu: " . $input);
$data = json_decode($input, true);
if (!isset($data['request_id'], $data['action'], $data['validator_id'])) {
error_log("validateRequest - Données manquantes: " . print_r($data, true));
echo json_encode([
"success" => false,
"message" => "Données manquantes pour la validation"
]);
exit();
}
$requestId = (int)$data['request_id'];
$action = $data['action']; // 'approve' ou 'reject'
$validatorId = (int)$data['validator_id'];
$comment = $data['comment'] ?? '';
error_log("validateRequest - Request ID: $requestId, Action: $action, Validator: $validatorId");
try {
$conn->begin_transaction();
// Vérifier que la demande existe et est en attente
$queryCheck = "
SELECT dc.Id, dc.EmployeeId, dc.TypeCongeId, dc.DateDebut, dc.DateFin, dc.NombreJours,
u.Nom, u.Prenom, tc.Nom as TypeNom
FROM DemandeConge dc
JOIN Users u ON dc.EmployeeId = u.ID
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
WHERE dc.Id = ? AND dc.Statut = 'En attente'
";
$stmtCheck = $conn->prepare($queryCheck);
$stmtCheck->bind_param("i", $requestId);
$stmtCheck->execute();
$resultCheck = $stmtCheck->get_result();
if ($requestRow = $resultCheck->fetch_assoc()) {
$employeeId = $requestRow['EmployeeId'];
$typeCongeId = $requestRow['TypeCongeId'];
$nombreJours = $requestRow['NombreJours'];
$employeeName = $requestRow['Prenom'] . ' ' . $requestRow['Nom'];
$typeNom = $requestRow['TypeNom'];
error_log("validateRequest - Demande trouvée: $employeeName, Type: $typeNom, Jours: $nombreJours");
// Déterminer le nouveau statut
$newStatus = ($action === 'approve') ? 'Validée' : 'Refusée';
// Mettre à jour la demande
$queryUpdate = "
UPDATE DemandeConge
SET Statut = ?,
ValidateurId = ?,
DateValidation = NOW(),
CommentaireValidation = ?
WHERE Id = ?
";
$stmtUpdate = $conn->prepare($queryUpdate);
$stmtUpdate->bind_param("sisi", $newStatus, $validatorId, $comment, $requestId);
if ($stmtUpdate->execute()) {
error_log("validateRequest - Demande mise à jour avec succès");
// Si approuvée, déduire du solde (sauf pour congé maladie)
if ($action === 'approve' && $typeNom !== 'Congé maladie') {
// Déterminer l'année selon le type de congé
$currentDate = new DateTime();
if ($typeNom === 'Congé payé') {
// Exercice CP: 01/06 au 31/05
$year = ($currentDate->format('m') < 6) ? $currentDate->format('Y') - 1 : $currentDate->format('Y');
} else {
// RTT: année civile
$year = $currentDate->format('Y');
}
error_log("validateRequest - Déduction solde: Type=$typeNom, Année=$year, Jours=$nombreJours");
// Déduire du solde
$queryDeduct = "
UPDATE CompteurConges
SET Solde = GREATEST(0, Solde - ?)
WHERE EmployeeId = ? AND TypeCongeId = ? AND Annee = ?
";
$stmtDeduct = $conn->prepare($queryDeduct);
$stmtDeduct->bind_param("diii", $nombreJours, $employeeId, $typeCongeId, $year);
if ($stmtDeduct->execute()) {
error_log("validateRequest - Solde déduit avec succès");
} else {
error_log("validateRequest - Erreur déduction solde: " . $stmtDeduct->error);
}
$stmtDeduct->close();
}
// Créer une notification pour l'employé
$notificationTitle = ($action === 'approve') ? 'Demande approuvée' : 'Demande refusée';
$notificationMessage = "Votre demande de $typeNom a été " . (($action === 'approve') ? 'approuvée' : 'refusée');
if ($comment) {
$notificationMessage .= ". Commentaire: $comment";
}
$queryNotif = "
INSERT INTO Notifications (UserId, Titre, Message, Type, DemandeCongeId)
VALUES (?, ?, ?, ?, ?)
";
$notifType = ($action === 'approve') ? 'Success' : 'Error';
$stmtNotif = $conn->prepare($queryNotif);
$stmtNotif->bind_param("isssi", $employeeId, $notificationTitle, $notificationMessage, $notifType, $requestId);
$stmtNotif->execute();
$stmtNotif->close();
// Log dans l'historique
$actionText = ($action === 'approve') ? 'Validation congé' : 'Refus congé';
$actionDetails = "$actionText $employeeName ($typeNom)";
if ($comment) {
$actionDetails .= " - $comment";
}
$queryHistory = "
INSERT INTO HistoriqueActions (UserId, Action, Details, DemandeCongeId)
VALUES (?, ?, ?, ?)
";
$stmtHistory = $conn->prepare($queryHistory);
$stmtHistory->bind_param("issi", $validatorId, $actionText, $actionDetails, $requestId);
$stmtHistory->execute();
$stmtHistory->close();
$conn->commit();
echo json_encode([
"success" => true,
"message" => "Demande " . (($action === 'approve') ? 'approuvée' : 'refusée') . " avec succès",
"new_status" => $newStatus
]);
} else {
throw new Exception("Erreur lors de la mise à jour: " . $stmtUpdate->error);
}
$stmtUpdate->close();
} else {
throw new Exception("Demande non trouvée ou déjà traitée");
}
$stmtCheck->close();
} catch (Exception $e) {
$conn->rollback();
error_log("Erreur validateRequest: " . $e->getMessage());
echo json_encode([
"success" => false,
"message" => "Erreur lors de la validation: " . $e->getMessage()
]);
}
$conn->close();
?>

View File

@@ -1,44 +1,114 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
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>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
} />
<Route path="/demandes" element={
<ProtectedRoute>
<Requests />
</ProtectedRoute>
} />
<Route path="/calendrier" element={
<ProtectedRoute>
<Calendar />
</ProtectedRoute>
} />
<Route path="/manager" element={
<ProtectedRoute>
<Manager />
</ProtectedRoute>
} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
<AppContent />
</Router>
</AuthProvider>
);
}
export default App;
export default App;

62
project/src/AuthConfig.js Normal file
View 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
});

View 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;

View 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;

View 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;

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,35 @@
import React from 'react';
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const ProtectedRoute = ({ children }) => {
const { user, isLoading } = useAuth();
const ProtectedRoute = ({ children, allowedRoles = [] }) => {
const { isAuthorized, user, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement...</p>
</div>
</div>
);
}
// ✅ 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>
);
}
return user ? children : <Navigate to="/login" replace />;
// ✅ 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;

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { LogOut, Calendar, Home, FileText, Building2, Menu, X, Users } from 'lucide-react';
import { LogOut, Calendar, Home, FileText, X, Users, Clock } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
const Sidebar = ({ isOpen, onToggle }) => {
@@ -9,9 +9,69 @@ const Sidebar = ({ isOpen, onToggle }) => {
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 (
<>
{/* Mobile overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
@@ -19,35 +79,28 @@ const Sidebar = ({ isOpen, onToggle }) => {
/>
)}
{/* Sidebar */}
<div className={`
fixed inset-y-0 left-0 z-50 w-60 bg-white border-r border-gray-200 min-h-screen flex flex-col transform transition-transform duration-300 ease-in-out
${isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
{/* Mobile close button */}
<div
className={`
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"
>
<button onClick={onToggle} className="p-2 rounded-lg hover:bg-gray-100">
<X className="w-6 h-6" />
</button>
</div>
{/* Logo Section */}
<div className="p-6 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
<Building2 className="w-6 h-6 text-white" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900">GTA</h2>
<p className="text-sm text-gray-500">Gestion de congés</p>
</div>
<div className="flex flex-col items-center gap-2">
<img
src="/assets/GA.svg"
alt="GTA Logo"
className="h-24 w-auto"
/>
</div>
</div>
{/* User Info */}
<div className="p-4 lg:p-6 border-b border-gray-100">
<div className="flex flex-col items-center text-center">
<img
@@ -56,73 +109,97 @@ const Sidebar = ({ isOpen, onToggle }) => {
className="w-12 h-12 lg:w-16 lg:h-16 rounded-full object-cover mb-3"
/>
<div>
<p className="font-semibold text-gray-900 text-sm lg:text-base">{user?.name || "Utilisateur"}</p>
<p className="text-xs lg:text-sm text-gray-500">{user?.department || "Service"}</p>
<span className="inline-block mt-2 px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
Employé
</span>
<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>
{/* Navigation */}
<nav className="flex-1 p-4">
<div className="space-y-2">
<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="/dashboard"
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("/dashboard")
? "bg-blue-50 text-blue-700 border-r-2 border-blue-700"
: "text-gray-700 hover:bg-gray-50"
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"
}`}
>
<Home className="w-5 h-5" />
<span className="font-medium">Dashboard</span>
<Clock className="w-5 h-5" />
<span className="font-medium">CRA</span>
</Link>
)}
{hasTeamAccess && (
<Link
to="/demandes"
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("/demandes")
? "bg-blue-50 text-blue-700 border-r-2 border-blue-700"
: "text-gray-700 hover:bg-gray-50"
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"
}`}
>
<FileText className="w-5 h-5" />
<span className="font-medium">Demandes</span>
<Users className="w-5 h-5" />
<span className="font-medium">Mon équipe</span>
</Link>
<Link
to="/calendrier"
onClick={() => window.innerWidth < 1024 && onToggle()}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive("/calendrier")
? "bg-blue-50 text-blue-700 border-r-2 border-blue-700"
: "text-gray-700 hover:bg-gray-50"
}`}
>
<Calendar className="w-5 h-5" />
<span className="font-medium">Calendrier</span>
</Link>
{(user?.role === 'Manager' || user?.role === 'Admin' || user?.role === 'Employe') && (
<Link
to="/manager"
onClick={() => window.innerWidth < 1024 && onToggle()}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive("/manager")
? "bg-blue-50 text-blue-700 border-r-2 border-blue-700"
: "text-gray-700 hover:bg-gray-50"
}`}
>
<Users className="w-5 h-5" />
<span className="font-medium">
{user?.role === 'Employe' ? 'Mon équipe' : 'Équipe'}
</span>
</Link>
)}
</div>
)}
</nav>
{/* Logout Button */}
<div className="p-4 border-t border-gray-100">
<button
onClick={logout}
@@ -137,4 +214,4 @@ const Sidebar = ({ isOpen, onToggle }) => {
);
};
export default Sidebar;
export default Sidebar;

View File

@@ -1,126 +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');
}
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);
useEffect(() => {
// Vérifier si l'utilisateur est déjà connecté
const savedUser = localStorage.getItem('user');
if (savedUser) {
try {
setUser(JSON.parse(savedUser));
} catch (error) {
console.error('Erreur lors du parsing de l\'utilisateur sauvegardé:', error);
localStorage.removeItem('user');
}
}
setIsLoading(false);
}, []);
const getApiUrl = (endpoint) => {
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
return `${API_BASE_URL}/${cleanEndpoint}`;
};
const login = async (email, password) => {
// --- Vérifie l'autorisation de l'utilisateur via groupes
const checkUserAuthorization = async (userPrincipalName, accessToken) => {
try {
// Tester plusieurs URLs possibles selon la configuration locale
const possibleUrls = [
'http://localhost/GTA/project/public/login.php',
'http://localhost:80/GTA/project/public/login.php',
'http://localhost/GTA/public/login.php',
'http://localhost/public/login.php'
];
const response = await fetch(getApiUrl('check-user-groups'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({ userPrincipalName })
});
let response = null;
let lastError = null;
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);
for (const url of possibleUrls) {
try {
console.log(' Test URL:', url);
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
mot_de_passe: password
}),
// Définir le compte actif
instance.setActiveAccount(account);
// Acquérir un token silencieusement
const tokenResponse = await instance.acquireTokenSilent({
...loginRequest,
account: account
});
if (response.ok) {
console.log(' URL qui fonctionne:', url);
break;
}
console.log('✅ Token acquis silencieusement');
await handleSuccessfulAuth(tokenResponse);
} catch (error) {
lastError = error;
console.log(' URL échouée:', url, error.message);
continue;
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);
}
};
if (!response || !response.ok) {
throw new Error('Aucune URL de connexion accessible');
}
processAuthentication();
}, [instance, accounts, inProgress]);
const text = await response.text();
console.log(' Réponse brute:', text);
// --- 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 })
});
// Vérifier si la réponse est du JSON valide
let data;
try {
data = JSON.parse(text);
} catch (parseError) {
console.error(' Réponse non-JSON:', text.substring(0, 200));
throw new Error('Le serveur PHP ne répond pas correctement. Vérifiez que PHP est démarré.');
}
if (!response.ok) throw new Error('Erreur de connexion');
const data = await response.json();
if (data.success) {
const userData = {
setUser({
id: data.user.id,
name: data.user.prenom + ' ' + data.user.nom,
name: `${data.user.prenom} ${data.user.nom}`,
prenom: data.user.prenom,
nom: data.user.nom,
email: data.user.email,
role: data.user.role || 'Employe'
};
setUser(userData);
localStorage.setItem('user', JSON.stringify(userData));
role: data.user.role || 'Employe',
service: data.user.service || 'Non défini'
});
setIsAuthorized(true);
return true;
} else {
console.error(' Échec connexion:', data.message);
return false;
}
return false;
} catch (error) {
console.error('Erreur de connexion:', error);
console.error("Erreur de connexion:", error);
return false;
}
};
const logout = () => {
setUser(null);
localStorage.removeItem('user');
// --- 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
isLoading,
getAccessToken
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export default AuthContext;

View File

@@ -1,10 +1,123 @@
import { StrictMode } from 'react';
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";
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
// ✅ 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();

File diff suppressed because it is too large Load Diff

View 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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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;

View File

@@ -1,40 +1,81 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import { Building2, Mail, Lock, Eye, EyeOff } from 'lucide-react';
import { AlertTriangle } from 'lucide-react';
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
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('');
const success = await login(email, password);
if (success) {
navigate('/dashboard');
} else {
setError('Identifiants incorrects. Veuillez réessayer.');
}
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();
setIsLoading(false);
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>
@@ -43,78 +84,65 @@ const Login = () => {
<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 lg:mb-8">
<div className="w-12 h-12 lg:w-16 lg:h-16 bg-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Building2 className="w-6 h-6 lg:w-8 lg:h-8 text-white" />
</div>
<h1 className="text-xl lg:text-2xl font-bold text-gray-900">GTA</h1>
<p className="text-sm lg:text-base text-gray-600">Gestion de congés</p>
<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>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4 lg:space-y-6">
<div>
<label htmlFor="email" className="block text-sm lg:text-base font-medium text-gray-700 mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4 lg:w-5 lg:h-5" />
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full pl-9 lg:pl-10 pr-4 py-2 lg:py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm lg:text-base"
placeholder="votre.email@entreprise.com"
required
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm lg:text-base font-medium text-gray-700 mb-2">
Mot de passe
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4 lg:w-5 lg:h-5" />
<input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-9 lg:pl-10 pr-10 lg:pr-12 py-2 lg:py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm lg:text-base"
placeholder="••••••••"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label={showPassword ? "Masquer le mot de passe" : "Afficher le mot de passe"}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</div>
{error && (
<div className="p-2 lg:p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-xs lg:text-sm">{error}</p>
</div>
)}
{/* Bouton Office 365 */}
<div className="mb-4">
<button
type="submit"
data-testid="o365-login-btn"
onClick={handleO365Login}
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 lg:py-3 px-4 rounded-lg font-medium hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm lg:text-base"
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 ? 'Connexion...' : 'Se connecter'}
{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>
</form>
</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>

View File

@@ -1,472 +1,557 @@
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';
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 === 'Employe';
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 [selectedRequest, setSelectedRequest] = useState(null);
const [showValidationModal, setShowValidationModal] = useState(false);
const [validationComment, setValidationComment] = useState('');
const [validationAction, setValidationAction] = useState('');
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();
}
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();
await Promise.all([
fetchTeamMembers(),
fetchPendingRequests(),
fetchAllTeamRequests(),
]);
} catch (error) {
console.error('Erreur lors de la récupération des données équipe:', 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 response = await fetch(`http://localhost/GTA/project/public/getTeamMembers.php?manager_id=${user.id}`);
const text = await response.text();
console.log('Réponse équipe:', text);
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
});
const data = JSON.parse(text);
if (data.success) {
setTeamMembers(data.team_members || []);
} else {
console.error('❌ Erreur getTeamMembers:', data.message);
setTeamMembers([]);
}
} catch (error) {
console.error('Erreur récupération équipe:', error);
console.error('Erreur fetch getTeamMembers:', error);
setTeamMembers([]);
}
};
// ✅ SIMPLIFIÉ - Le backend gère tout le filtrage
const fetchPendingRequests = async () => {
try {
const response = await fetch(`http://localhost/GTA/project/public/getPendingRequests.php?manager_id=${user.id}`);
const text = await response.text();
console.log('Réponse demandes en attente:', text);
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
});
const data = JSON.parse(text);
if (data.success) {
setPendingRequests(data.requests || []);
} else {
console.error('❌ Erreur getPendingRequests:', data.message);
setPendingRequests([]);
}
} catch (error) {
console.error('Erreur récupération demandes en attente:', error);
console.error('Erreur fetch getPendingRequests:', error);
setPendingRequests([]);
}
};
// ✅ SIMPLIFIÉ - Le backend gère tout le filtrage
const fetchAllTeamRequests = async () => {
try {
const response = await fetch(`http://localhost/GTA/project/public/getAllTeamRequests.php?manager_id=${user.id}`);
const text = await response.text();
console.log('Réponse toutes demandes équipe:', text);
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
});
const data = JSON.parse(text);
if (data.success) {
setAllRequests(data.requests || []);
} else {
console.error('❌ Erreur getAllTeamRequests:', data.message);
setAllRequests([]);
}
} catch (error) {
console.error('Erreur récupération toutes demandes:', error);
console.error('Erreur fetch getAllTeamRequests:', error);
setAllRequests([]);
}
};
const handleValidateRequest = async (requestId, action, comment = '') => {
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 {
const response = await fetch('http://localhost/GTA/project/public/validateRequest.php', {
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}`);
}
await handleValidateRequest(request.id, action, comment);
showToast("success", action === "approve" ? "Demande approuvée avec succès" : "Demande refusée");
closeValidationModal();
} catch (error) {
console.error('Erreur validation:', error);
alert('Erreur lors de la validation');
showToast("error", "Une erreur est survenue");
setIsValidating(false);
}
};
const openValidationModal = (request, action) => {
setSelectedRequest(request);
setValidationAction(action);
setValidationComment('');
setShowValidationModal(true);
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';
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';
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">
<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 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="min-h-screen bg-gray-50 flex">
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<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>
<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"
{/* 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"
>
<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>
<p className="text-sm lg:text-base text-gray-600">
{isEmployee ? 'Consultez les congés de votre équipe' : 'Gérez les demandes de congés de votre équipe'}
</p>
</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 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>
<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 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">Approuvées</p>
<p className="text-xl lg:text-2xl font-bold text-gray-900">
{allRequests.filter(r => r.status === 'Validée' || r.status === 'Approuvé').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-green-100 rounded-lg flex items-center justify-center">
<CheckCircle className="w-4 h-4 lg:w-6 lg:h-6 text-green-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">Refusées</p>
<p className="text-xl lg:text-2xl font-bold text-gray-900">
{allRequests.filter(r => r.status === 'Refusée').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-red-100 rounded-lg flex items-center justify-center">
<XCircle className="w-4 h-4 lg:w-6 lg:h-6 text-red-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 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>
)}
{/* É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="flex items-center justify-between p-3 border border-gray-100 rounded-lg hover:bg-gray-50 transition-colors">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<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">Soumis le {request.submitted_display}</p>
</div>
<div className="text-right">
<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">
<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>
<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>
)}
<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="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>
<div className="flex gap-3">
<div className="p-6 border-t border-gray-100 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"
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={() => 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'
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"
}`}
>
{validationAction === 'approve' ? 'Approuver' : 'Refuser'}
{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>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
import '@testing-library/jest-dom';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});

View 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 lapparition de lindication 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 lapparition du menu contextuel (modal)
const leaveModal = await screen.findByTestId('leave-modal', {}, { timeout: 5000 });
expect(leaveModal).toBeTruthy();
expect(screen.getByText('Formulaire complet...')).toBeTruthy();
}, 20000);
});

View 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();
});
});
});

View 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']
}
}
}
}
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

View File

@@ -1,10 +1,28 @@
import { defineConfig } from 'vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
});
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',
},
});

153
setup-complete.ps1 Normal file
View File

@@ -0,0 +1,153 @@
Write-Host "=== Configuration complète du projet GTA ===" -ForegroundColor Cyan
# 1. Créer la structure
Write-Host "`n1. Création de la structure..." -ForegroundColor Yellow
$dirs = @(
"C:\GTA\project\public\backend",
"C:\GTA\project\public\backend\uploads",
"C:\GTA\project\src"
)
foreach ($dir in $dirs) {
if (!(Test-Path $dir)) {
New-Item -Path $dir -ItemType Directory -Force | Out-Null
Write-Host " ✓ Créé: $dir" -ForegroundColor Green
}
}
# 2. Backend package.json
Write-Host "`n2. Création de package.json..." -ForegroundColor Yellow
$backendPackage = @"
{
"name": "gta-backend",
"version": "1.0.0",
"description": "GTA Backend API",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"mysql2": "^3.6.5",
"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"
},
"engines": {
"node": ">=18.0.0"
}
}
"@
Set-Content -Path "C:\GTA\project\backend\package.json" -Value $backendPackage
Write-Host " ✓ package.json créé" -ForegroundColor Green
# 3. Backend .env
Write-Host "`n3. Création de .env..." -ForegroundColor Yellow
$envContent = @"
DB_HOST=mysql
DB_USER=wpuser
DB_PASSWORD=-2b/)ru5/Bi8P[7_
DB_NAME=DemandeConge
PORT=3000
NODE_ENV=production
AZURE_TENANT_ID=9840a2a0-6ae1-4688-b03d-d2ec291be0f9
AZURE_CLIENT_ID=4bb4cc24-bac3-427c-b02c-5d14fc67b561
AZURE_CLIENT_SECRET=gvf8Q~545Bafn8yYsgjW~QG_P1lpzaRe6gJNgb2t
AZURE_GROUP_ID=c1ea877c-6bca-4f47-bfad-f223640813a0
EMAIL_FROM=gtanoreply@ensup.eu
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=5242880
"@
Set-Content -Path "C:\GTA\project\backend\.env" -Value $envContent
Write-Host " ✓ .env créé" -ForegroundColor Green
# 4. Backend server.js
Write-Host "`n4. Création de server.js..." -ForegroundColor Yellow
$serverJs = @"
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
env: {
dbHost: process.env.DB_HOST,
dbName: process.env.DB_NAME
}
});
});
app.get('/api/test', (req, res) => {
res.json({ message: 'Backend GTA opérationnel!' });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(\` Serveur démarré sur le port \${PORT}\`);
});
"@
Set-Content -Path "C:\GTA\project\backend\server.js" -Value $serverJs
Write-Host " ✓ server.js créé" -ForegroundColor Green
# 5. Backend Dockerfile
Write-Host "`n5. Création de Dockerfile..." -ForegroundColor Yellow
$dockerfile = @"
FROM node:18-alpine AS base
RUN apk add --no-cache curl mysql-client
WORKDIR /app
COPY package*.json ./
RUN if [ -f package-lock.json ]; then \
npm ci --omit=dev; \
else \
npm install --production; \
fi && npm cache clean --force
COPY . .
RUN mkdir -p /app/uploads && chmod 755 /app/uploads
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "server.js"]
"@
Set-Content -Path "C:\GTA\project\backend\DockerfileGTA.backend" -Value $dockerfile
Write-Host " ✓ Dockerfile créé" -ForegroundColor Green
# 6. Afficher le résumé
Write-Host "`n=== Configuration terminée ===" -ForegroundColor Green
Write-Host "`nFichiers créés:" -ForegroundColor Cyan
Get-ChildItem C:\GTA\project\public\Backend | Select-Object Name, Length
Write-Host "`n=== Prochaines étapes ===" -ForegroundColor Yellow
Write-Host "1. cd C:\GTA" -ForegroundColor White
Write-Host "2. docker-compose up --build -d" -ForegroundColor White
Write-Host "3. docker-compose logs -f backend" -ForegroundColor White