This commit is contained in:
2025-10-02 12:55:40 +02:00
parent 8317094a4c
commit 297dbc2ae5
15 changed files with 1184 additions and 499 deletions

30
GTFRRH/docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
services:
backend:
build:
context: ./project/backend/config
dockerfile: DockerfileGTFRH.backend
ports:
- "8000:3001"
environment:
- DB_SERVER=192.168.0.3
- DB_DATABASE=GTF
- DB_USER=gtf_app
- DB_PASSWORD=GTF2025!Secure
- DB_ENCRYPT=true
- DB_TRUST_SERVER_CERTIFICATE=true
- PORT=3000
extra_hosts:
- "BONEMINE:192.168.0.3"
- "bonemine.ensup.local:192.168.0.3"
frontend:
build:
context: ./project
dockerfile: DockerfileGTFRH.frontend
ports:
- "3002:3002"
environment:
- NODE_ENV=development
- VITE_API_BASE_URL=https://mygtf-rh.ensup-adm.net:8000
depends_on:
- backend

View File

@@ -0,0 +1,42 @@
FROM node:18
WORKDIR /GTFRRH/project
COPY package*.json ./
RUN rm -rf node_modules package-lock.json && \
npm install --include=optional
COPY . .
# Override du vite.config.ts avec allowedHosts
RUN cat > vite.config.ts << 'EOF'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3002,
strictPort: true,
allowedHosts: [
'mygtf-rh.ensup-adm.net',
'localhost',
'127.0.0.1'
],
proxy: {
'/api': {
target: 'http://backend:3000',
changeOrigin: true,
secure: false,
ws: true
}
}
}
})
EOF
EXPOSE 3002
CMD ["npm", "run", "dev"]

View File

@@ -0,0 +1,18 @@
# DockerfileGTFRH.backend
FROM node:18-alpine
WORKDIR /GTFRRH/project
# Copy only package files first for caching
COPY package*.json ./
# Install backend dependencies inside container
RUN rm -rf node_modules package-lock.json && npm install
# Copy backend source code
COPY . .
EXPOSE 8001
CMD ["node", "serv.js"]

View File

@@ -1,11 +1,11 @@
{ {
"name": "config", "name": "config",
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"main": "server.js", "main": "serv.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js" "start": "node serv.js"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -14,7 +14,8 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"express": "^5.1.0", "express": "^5.1.0",
"mssql": "^11.0.1" "mssql": "^11.0.1",
"axios": "^1.7.0"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.19" "@types/cors": "^2.8.19"

View File

@@ -5,7 +5,7 @@ const axios = require('axios');
require('dotenv').config(); require('dotenv').config();
const app = express(); const app = express();
const PORT = 3002; const PORT = process.env.PORT || 8000;
// Configuration base de données // Configuration base de données
const dbConfig = { const dbConfig = {
@@ -23,16 +23,20 @@ const dbConfig = {
// Configuration Microsoft OAuth // Configuration Microsoft OAuth
const CLIENT_ID = 'cd99bbea-dcd4-4a76-a0b0-7aeb49931943'; const CLIENT_ID = 'cd99bbea-dcd4-4a76-a0b0-7aeb49931943';
const TENANT_ID = '9840a2a0-6ae1-4688-b03d-d2ec291be0f9'; const TENANT_ID = '9840a2a0-6ae1-4688-b03d-d2ec291be0f9';
const REDIRECT_URI = 'http://localhost:5174'; const REDIRECT_URI = 'https://mygtf-rh.ensup-adm.net';
const CLIENT_SECRET = 'F5G8Q~qWNzuMdghyIwTX20cAVjqAK4sz~1uEUaLB'; const CLIENT_SECRET = 'F5G8Q~qWNzuMdghyIwTX20cAVjqAK4sz~1uEUaLB';
const GROUP_ID = 'c1ea877c-6bca-4f47-bfad-f223640813a0'; const GROUP_ID = 'c1ea877c-6bca-4f47-bfad-f223640813a0';
// Middleware // Middleware
app.use(cors({ app.use(cors({
origin: ['http://localhost:5174', 'http://localhost:5173', 'http://localhost:3000'], origin: [
credentials: true, 'http://localhost:3001',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], 'http://localhost:8000',
allowedHeaders: ['Content-Type', 'Authorization'] 'http://127.0.0.1:3001',
'https://mygtf-rh.ensup-adm.net:3001',
'https://mygtf-rh.ensup-adm.net'
],
credentials: true
})); }));
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
@@ -85,23 +89,24 @@ async function checkSystemStatus() {
`); `);
systemStatus.hasFormateurEmailColumn = columnCheck.recordset[0].count > 0; systemStatus.hasFormateurEmailColumn = columnCheck.recordset[0].count > 0;
// 2. Vérifier si la vue Formateurs existe // 2. Vérifier si la vue v_Formateurs_CD existe
const viewCheck = await pool.request().query(` const viewCheck = await pool.request().query(`
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM INFORMATION_SCHEMA.VIEWS FROM INFORMATION_SCHEMA.VIEWS
WHERE TABLE_NAME = 'Formateurs' WHERE TABLE_SCHEMA = 'dbo'
AND TABLE_NAME = 'v_Formateurs_CD'
`); `);
systemStatus.hasFormateurView = viewCheck.recordset[0].count > 0; systemStatus.hasFormateurView = viewCheck.recordset[0].count > 0;
// 3. Tester l'accès à la vue Formateurs si elle existe // 3. Tester l'accès à la vue v_Formateurs_CD si elle existe
if (systemStatus.hasFormateurView) { if (systemStatus.hasFormateurView) {
try { try {
await pool.request().query(`SELECT TOP 1 userPrincipalName FROM [dbo].[Formateurs]`); await pool.request().query(`SELECT TOP 1 userPrincipalName FROM [GTF].[dbo].[v_Formateurs_CD]`);
systemStatus.canAccessFormateurView = true; systemStatus.canAccessFormateurView = true;
console.log('✅ Accès à la vue Formateurs: OK (RH)'); console.log('✅ Accès à la vue v_Formateurs_CD: OK (RH)');
} catch (error) { } catch (error) {
systemStatus.canAccessFormateurView = false; systemStatus.canAccessFormateurView = false;
console.log('❌ Accès à la vue Formateurs: ERREUR (RH) -', error.message); console.log('❌ Accès à la vue v_Formateurs_CD: ERREUR (RH) -', error.message);
} }
} }
@@ -128,8 +133,8 @@ async function checkSystemStatus() {
console.log('📊 État du système RH:'); console.log('📊 État du système RH:');
console.log(` - Colonne formateur_email_fk: ${systemStatus.hasFormateurEmailColumn ? '✅' : '❌'}`); console.log(` - Colonne formateur_email_fk: ${systemStatus.hasFormateurEmailColumn ? '✅' : '❌'}`);
console.log(` - Vue Formateurs: ${systemStatus.hasFormateurView ? '✅' : '❌'}`); console.log(` - Vue v_Formateurs_CD: ${systemStatus.hasFormateurView ? '✅' : '❌'}`);
console.log(` - Accès vue Formateurs: ${systemStatus.canAccessFormateurView ? '✅' : '❌'}`); console.log(` - Accès vue v_Formateurs_CD: ${systemStatus.canAccessFormateurView ? '✅' : '❌'}`);
console.log(` - Table formateurs_local: ${systemStatus.hasFormateurLocal ? '✅' : '❌'}`); console.log(` - Table formateurs_local: ${systemStatus.hasFormateurLocal ? '✅' : '❌'}`);
console.log(` - Mode de fonctionnement RH: ${systemStatus.operatingMode}`); console.log(` - Mode de fonctionnement RH: ${systemStatus.operatingMode}`);
@@ -139,13 +144,33 @@ async function checkSystemStatus() {
} }
} }
// À ajouter dans votre serveur RH (server.js) // Fonction utilitaire pour parser Nom_brut en nom et prénom
function parseNomBrut(nomBrut) {
if (!nomBrut) {
return { nom: '', prenom: '' };
}
const parts = nomBrut.trim().split(/\s+/);
if (parts.length > 1) {
return {
nom: parts[0],
prenom: parts.slice(1).join(' ')
};
} else {
return {
nom: parts[0],
prenom: ''
};
}
}
// Route de diagnostic des campus
app.get('/api/debug-campus', async (req, res) => { app.get('/api/debug-campus', async (req, res) => {
try { try {
// Vérifier les campus distincts dans la vue // Vérifier les campus distincts dans la vue
const campusResult = await pool.request().query(` const campusResult = await pool.request().query(`
SELECT DISTINCT Campus, COUNT(*) as nb_formateurs SELECT DISTINCT Campus, COUNT(*) as nb_formateurs
FROM [dbo].[Formateurs] FROM [GTF].[dbo].[v_Formateurs_CD]
GROUP BY Campus GROUP BY Campus
ORDER BY Campus ORDER BY Campus
`); `);
@@ -154,19 +179,18 @@ app.get('/api/debug-campus', async (req, res) => {
const sampleResult = await pool.request().query(` const sampleResult = await pool.request().query(`
SELECT TOP 10 SELECT TOP 10
userPrincipalName, userPrincipalName,
displayName, Nom_brut,
Campus, Campus,
surname, Contrat
givenname FROM [GTF].[dbo].[v_Formateurs_CD]
FROM [dbo].[Formateurs] ORDER BY Campus, Nom_brut
ORDER BY Campus, displayName
`); `);
res.json({ res.json({
success: true, success: true,
campus_distincts: campusResult.recordset, campus_distincts: campusResult.recordset,
echantillon_formateurs: sampleResult.recordset, echantillon_formateurs: sampleResult.recordset,
message: 'Diagnostic des campus dans la vue Formateurs' message: 'Diagnostic des campus dans la vue v_Formateurs_CD'
}); });
} catch (error) { } catch (error) {
@@ -233,7 +257,6 @@ app.get('/api/debug-campus-local', async (req, res) => {
} }
}); });
// Fonction pour générer un hash reproductible depuis un email (mode legacy) // Fonction pour générer un hash reproductible depuis un email (mode legacy)
function generateHashFromEmail(email) { function generateHashFromEmail(email) {
let hash = 0; let hash = 0;
@@ -449,11 +472,11 @@ app.get('/api/diagnostic', async (req, res) => {
break; break;
case 'new_with_local': case 'new_with_local':
recommendations.push('⚠️ Fonctionne avec la table locale - pas d\'accès à la vue distante'); recommendations.push('⚠️ Fonctionne avec la table locale - pas d\'accès à la vue distante');
recommendations.push('💡 Vérifier les permissions sur HP-TO-O365 pour utiliser la vue'); recommendations.push('💡 Vérifier les permissions sur GTF pour utiliser la vue');
break; break;
case 'new_email_only': case 'new_email_only':
recommendations.push('⚠️ Mode dégradé - sauvegarde par email mais pas de détails formateurs'); recommendations.push('⚠️ Mode dégradé - sauvegarde par email mais pas de détails formateurs');
recommendations.push('💡 Restaurer l\'accès à la vue Formateurs ou table formateurs_local'); recommendations.push('💡 Restaurer l\'accès à la vue v_Formateurs_CD ou table formateurs_local');
break; break;
case 'legacy_hash': case 'legacy_hash':
recommendations.push('🔄 Mode compatibilité - utilise l\'ancien système de hash'); recommendations.push('🔄 Mode compatibilité - utilise l\'ancien système de hash');
@@ -552,7 +575,7 @@ app.get('/api/db-test', async (req, res) => {
let formateurCount = 0; let formateurCount = 0;
try { try {
if (systemStatus.canAccessFormateurView) { if (systemStatus.canAccessFormateurView) {
const formateurResult = await pool.request().query('SELECT COUNT(*) as total FROM [dbo].[Formateurs]'); const formateurResult = await pool.request().query('SELECT COUNT(*) as total FROM [GTF].[dbo].[v_Formateurs_CD]');
formateurCount = formateurResult.recordset[0].total; formateurCount = formateurResult.recordset[0].total;
} else if (systemStatus.hasFormateurLocal) { } else if (systemStatus.hasFormateurLocal) {
const formateurResult = await pool.request().query('SELECT COUNT(*) as total FROM formateurs_local'); const formateurResult = await pool.request().query('SELECT COUNT(*) as total FROM formateurs_local');
@@ -591,6 +614,136 @@ app.put('/api/declarations/:id/status', async (req, res) => {
} }
}); });
// Route pour supprimer une déclaration
app.delete('/api/declarations/:id', async (req, res) => {
try {
const { id } = req.params;
console.log(`🗑️ Tentative de suppression de la déclaration ID: ${id}`);
// Vérifier si la déclaration existe
const existing = await pool.request()
.input('id', sql.Int, id)
.query('SELECT id, formateur_email_fk FROM declarations WHERE id = @id');
if (existing.recordset.length === 0) {
console.log(`❌ Déclaration ${id} introuvable`);
return res.status(404).json({
success: false,
message: 'Déclaration introuvable'
});
}
console.log(`✅ Déclaration ${id} trouvée, suppression en cours...`);
// Supprimer la déclaration
await pool.request()
.input('id', sql.Int, id)
.query('DELETE FROM declarations WHERE id = @id');
console.log(`✅ Déclaration ${id} supprimée avec succès`);
res.json({
success: true,
message: 'Déclaration supprimée avec succès'
});
} catch (error) {
console.error('❌ Erreur suppression déclaration:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Route pour modifier une déclaration
app.put('/api/declarations/:id', async (req, res) => {
try {
const { id } = req.params;
const { date, duree, heure_debut, heure_fin, description, type_demande_id } = req.body;
console.log(`✏️ Tentative de modification de la déclaration ID: ${id}`);
console.log('Données reçues:', { date, duree, heure_debut, heure_fin, type_demande_id });
// Validation
if (!date || !duree || !type_demande_id) {
console.log('❌ Données manquantes');
return res.status(400).json({
success: false,
message: 'Données manquantes (date, durée, type requis)'
});
}
// Vérifier si la déclaration existe
const existing = await pool.request()
.input('id', sql.Int, id)
.query('SELECT id FROM declarations WHERE id = @id');
if (existing.recordset.length === 0) {
console.log(`❌ Déclaration ${id} introuvable`);
return res.status(404).json({
success: false,
message: 'Déclaration introuvable'
});
}
console.log(`✅ Déclaration ${id} trouvée, mise à jour en cours...`);
// Formater les heures correctement pour SQL Server (ajouter :00 pour les secondes)
const formatTimeForSql = (timeString) => {
if (!timeString) return null;
// Si le format est HH:MM, ajouter :00 pour les secondes
if (timeString.match(/^\d{2}:\d{2}$/)) {
return timeString + ':00';
}
return timeString;
};
const heureDebutFormatted = formatTimeForSql(heure_debut);
const heureFinFormatted = formatTimeForSql(heure_fin);
console.log('Heures formatées:', {
original_debut: heure_debut,
formatted_debut: heureDebutFormatted,
original_fin: heure_fin,
formatted_fin: heureFinFormatted
});
// Mettre à jour la déclaration
await pool.request()
.input('id', sql.Int, id)
.input('date', sql.Date, date)
.input('duree', sql.Float, duree)
.input('heure_debut', sql.VarChar(8), heureDebutFormatted)
.input('heure_fin', sql.VarChar(8), heureFinFormatted)
.input('description', sql.NVarChar, description || null)
.input('type_demande_id', sql.Int, type_demande_id)
.query(`
UPDATE declarations
SET date = @date,
duree = @duree,
heure_debut = ${heureDebutFormatted ? '@heure_debut' : 'NULL'},
heure_fin = ${heureFinFormatted ? '@heure_fin' : 'NULL'},
description = @description,
type_demande_id = @type_demande_id
WHERE id = @id
`);
console.log(`✅ Déclaration ${id} modifiée avec succès`);
res.json({
success: true,
message: 'Déclaration modifiée avec succès'
});
} catch (error) {
console.error('❌ Erreur modification déclaration:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Route pour récupérer les déclarations (ADAPTÉE AU NOUVEAU SYSTÈME) // Route pour récupérer les déclarations (ADAPTÉE AU NOUVEAU SYSTÈME)
app.get('/api/get_declarations', async (req, res) => { app.get('/api/get_declarations', async (req, res) => {
try { try {
@@ -600,17 +753,15 @@ app.get('/api/get_declarations', async (req, res) => {
switch (systemStatus.operatingMode) { switch (systemStatus.operatingMode) {
case 'new_with_view': case 'new_with_view':
// Avec vue Formateurs // Avec vue v_Formateurs_CD
result = await pool.request().query(` result = await pool.request().query(`
SELECT SELECT
d.id, d.id,
d.utilisateur_id, d.utilisateur_id,
d.formateur_email_fk as formateur_email, d.formateur_email_fk as formateur_email,
f.displayName as formateur_nom_complet, f.Nom_brut as formateur_nom_complet,
f.surname as nom,
f.givenname as prenom,
f.Campus, f.Campus,
f.departement, f.Contrat,
td.id as type_demande_id, td.id as type_demande_id,
td.libelle as activityType, td.libelle as activityType,
d.date, d.date,
@@ -621,7 +772,7 @@ app.get('/api/get_declarations', async (req, res) => {
d.status d.status
FROM declarations d FROM declarations d
INNER JOIN types_demandes td ON d.type_demande_id = td.id INNER JOIN types_demandes td ON d.type_demande_id = td.id
LEFT JOIN [dbo].[Formateurs] f ON d.formateur_email_fk = f.userPrincipalName LEFT JOIN [GTF].[dbo].[v_Formateurs_CD] f ON d.formateur_email_fk = f.userPrincipalName
ORDER BY d.date DESC ORDER BY d.date DESC
`); `);
break; break;
@@ -702,8 +853,32 @@ app.get('/api/get_declarations', async (req, res) => {
// Traitement selon le mode // Traitement selon le mode
let processedResults = []; let processedResults = [];
if (systemStatus.operatingMode.startsWith('new_')) { if (systemStatus.operatingMode === 'new_with_view') {
// Nouveau système - données déjà enrichies par les jointures // Nouveau système avec v_Formateurs_CD - parsing du Nom_brut
processedResults = result.recordset.map(row => {
const { nom, prenom } = parseNomBrut(row.formateur_nom_complet);
return {
id: row.id,
utilisateur_id: row.utilisateur_id,
formateur_email: row.formateur_email,
type_demande_id: row.type_demande_id,
date: row.date,
duree: row.duree,
description: row.description,
heure_debut: formatSqlTime(row.heure_debut),
heure_fin: formatSqlTime(row.heure_fin),
status: row.status || 'pending',
activityType: row.activityType,
nom: nom || (row.formateur_email ? row.formateur_email.split('@')[0] : 'Inconnu'),
prenom: prenom,
campus: row.Campus || 'Non défini',
contrat: row.Contrat || '',
formateur_nom_complet: row.formateur_nom_complet || row.formateur_email || 'Utilisateur inconnu'
};
});
} else if (systemStatus.operatingMode === 'new_with_local') {
// Table locale avec structure complète
processedResults = result.recordset.map(row => ({ processedResults = result.recordset.map(row => ({
id: row.id, id: row.id,
utilisateur_id: row.utilisateur_id, utilisateur_id: row.utilisateur_id,
@@ -716,12 +891,30 @@ app.get('/api/get_declarations', async (req, res) => {
heure_fin: formatSqlTime(row.heure_fin), heure_fin: formatSqlTime(row.heure_fin),
status: row.status || 'pending', status: row.status || 'pending',
activityType: row.activityType, activityType: row.activityType,
// Informations formateur (peuvent être null si pas de jointure)
nom: row.nom || (row.formateur_email ? row.formateur_email.split('@')[0] : 'Inconnu'), nom: row.nom || (row.formateur_email ? row.formateur_email.split('@')[0] : 'Inconnu'),
prenom: row.prenom || '', prenom: row.prenom || '',
campus: row.Campus || 'Non défini', campus: row.Campus || 'Non défini',
formateur_nom_complet: row.formateur_nom_complet || row.formateur_email || 'Utilisateur inconnu' formateur_nom_complet: row.formateur_nom_complet || row.formateur_email || 'Utilisateur inconnu'
})); }));
} else if (systemStatus.operatingMode === 'new_email_only') {
// Mode email seulement
processedResults = result.recordset.map(row => ({
id: row.id,
utilisateur_id: row.utilisateur_id,
formateur_email: row.formateur_email,
type_demande_id: row.type_demande_id,
date: row.date,
duree: row.duree,
description: row.description,
heure_debut: formatSqlTime(row.heure_debut),
heure_fin: formatSqlTime(row.heure_fin),
status: row.status || 'pending',
activityType: row.activityType,
nom: row.formateur_email ? row.formateur_email.split('@')[0] : 'Inconnu',
prenom: '',
campus: 'Non défini',
formateur_nom_complet: row.formateur_email || 'Utilisateur inconnu'
}));
} else { } else {
// Ancien système - mapping manuel // Ancien système - mapping manuel
const knownMappings = { const knownMappings = {
@@ -799,7 +992,7 @@ app.get('/api/debug-hash', (req, res) => {
if (!email) { if (!email) {
return res.json({ return res.json({
error: 'Email requis', error: 'Email requis',
example: 'http://localhost:3002/api/debug-hash?email=oimer@ensup.eu' example: '/api/debug-hash?email=oimer@ensup.eu'
}); });
} }
@@ -812,7 +1005,7 @@ app.get('/api/debug-hash', (req, res) => {
}); });
}); });
// Nouvelle route pour les formateurs avec déclarations (ADAPTÉE) // Nouvelle route pour les formateurs avec déclarations
app.get('/api/formateurs-avec-declarations', async (req, res) => { app.get('/api/formateurs-avec-declarations', async (req, res) => {
try { try {
let result; let result;
@@ -820,56 +1013,80 @@ app.get('/api/formateurs-avec-declarations', async (req, res) => {
switch (systemStatus.operatingMode) { switch (systemStatus.operatingMode) {
case 'new_with_view': case 'new_with_view':
result = await pool.request().query(` result = await pool.request().query(`
SELECT DISTINCT SELECT DISTINCT
f.givenname, f.Nom_brut,
f.surname, f.Campus,
f.displayName, f.userPrincipalName,
f.Campus, f.Contrat,
f.userPrincipalName, COUNT(d.id) as nb_declarations
f.Jobtitle, FROM [GTF].[dbo].[v_Formateurs_CD] f
f.Contrat, LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk
COUNT(d.id) as nb_declarations WHERE LOWER(f.Contrat) LIKE 'cd%'
FROM [dbo].[Formateurs] f AND d.id IS NOT NULL
LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk GROUP BY f.Nom_brut, f.Campus, f.userPrincipalName, f.Contrat
WHERE (f.Contrat = 'CDD' OR f.Contrat LIKE '%CDD%') ORDER BY f.Nom_brut
AND d.id IS NOT NULL `);
GROUP BY f.givenname, f.surname, f.displayName, f.Campus, f.userPrincipalName, f.Jobtitle, f.Contrat
ORDER BY f.surname, f.givenname
`);
break; break;
case 'new_with_local': case 'new_with_local':
result = await pool.request().query(` result = await pool.request().query(`
SELECT DISTINCT SELECT DISTINCT
f.givenname, f.givenname,
f.surname, f.surname,
f.displayName, f.displayName,
f.Campus, f.Campus,
f.userPrincipalName, f.userPrincipalName,
f.Jobtitle, f.Jobtitle,
f.Contrat, f.Contrat,
COUNT(d.id) as nb_declarations COUNT(d.id) as nb_declarations
FROM formateurs_local f FROM formateurs_local f
LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk
WHERE (f.Contrat = 'CDD' OR f.Contrat LIKE '%CDD%') WHERE LOWER(f.Contrat) LIKE 'cd%'
AND d.id IS NOT NULL AND d.id IS NOT NULL
GROUP BY f.givenname, f.surname, f.displayName, f.Campus, f.userPrincipalName, f.Jobtitle, f.Contrat GROUP BY f.givenname, f.surname, f.displayName, f.Campus, f.userPrincipalName, f.Jobtitle, f.Contrat
ORDER BY f.surname, f.givenname ORDER BY f.surname, f.givenname
`); `);
break; break;
default:
return res.json({
success: false,
message: 'Cette fonctionnalité nécessite le nouveau système',
mode: systemStatus.operatingMode
});
} }
const formateurs = result.recordset.map(f => ({ let formateurs;
userPrincipalName: f.userPrincipalName,
displayName: f.displayName, if (systemStatus.operatingMode === 'new_with_view') {
nom: f.surname || '', // Parser le Nom_brut pour v_Formateurs_CD
prenom: f.givenname || '', formateurs = result.recordset.map(f => {
campus: f.Campus || 'Non défini', const { nom, prenom } = parseNomBrut(f.Nom_brut);
poste: f.Jobtitle || '',
contrat: f.Contrat || '', return {
nbDeclarations: f.nb_declarations, userPrincipalName: f.userPrincipalName,
displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'}) - CDD`.trim() displayName: f.Nom_brut,
})); nom: nom,
prenom: prenom,
campus: f.Campus || 'Non défini',
contrat: f.Contrat || '',
nbDeclarations: f.nb_declarations,
displayText: `${f.Nom_brut} (${f.Campus || 'Non défini'}) - CDD`.trim()
};
});
} else {
// Structure normale pour formateurs_local
formateurs = result.recordset.map(f => ({
userPrincipalName: f.userPrincipalName,
displayName: f.displayName,
nom: f.surname || '',
prenom: f.givenname || '',
campus: f.Campus || 'Non défini',
poste: f.Jobtitle || '',
contrat: f.Contrat || '',
nbDeclarations: f.nb_declarations,
displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'}) - CDD`.trim()
}));
}
res.json({ res.json({
success: true, success: true,
@@ -893,21 +1110,20 @@ app.get('/api/formateurs-vue', async (req, res) => {
console.log(`🔍 Récupération formateurs CDD seulement (mode: ${systemStatus.operatingMode})...`); console.log(`🔍 Récupération formateurs CDD seulement (mode: ${systemStatus.operatingMode})...`);
let formateurs = []; let formateurs = [];
let result; // Déclarer result ici ! let result;
if (systemStatus.canAccessFormateurView) { if (systemStatus.canAccessFormateurView) {
console.log('Utilisation de la vue Formateurs...'); console.log('Utilisation de la vue v_Formateurs_CD...');
result = await pool.request().query(` result = await pool.request().query(`
SELECT SELECT
userPrincipalName, userPrincipalName,
displayName, Nom_brut,
surname,
givenname,
Campus, Campus,
Contrat Contrat
FROM [dbo].[Formateurs] FROM [GTF].[dbo].[v_Formateurs_CD]
WHERE Contrat = 'CDD' OR Contrat LIKE '%CDD%' WHERE LOWER(Contrat) LIKE 'cd%'
ORDER BY surname, givenname
ORDER BY Nom_brut
`); `);
} else if (systemStatus.hasFormateurLocal) { } else if (systemStatus.hasFormateurLocal) {
console.log('Utilisation de formateurs_local...'); console.log('Utilisation de formateurs_local...');
@@ -920,7 +1136,8 @@ app.get('/api/formateurs-vue', async (req, res) => {
Campus, Campus,
Contrat Contrat
FROM formateurs_local FROM formateurs_local
WHERE Contrat = 'CDD' OR Contrat LIKE '%CDD%' WHERE LOWER(Contrat) LIKE 'cd%'
ORDER BY surname, givenname ORDER BY surname, givenname
`); `);
} else { } else {
@@ -939,9 +1156,18 @@ app.get('/api/formateurs-vue', async (req, res) => {
if (result.recordset.length === 0) { if (result.recordset.length === 0) {
console.log('Aucun formateur CDD trouvé, test sans filtre...'); console.log('Aucun formateur CDD trouvé, test sans filtre...');
// Test sans le filtre CDD pour voir s'il y a des formateurs
let testResult; let testResult;
if (systemStatus.hasFormateurLocal) { if (systemStatus.canAccessFormateurView) {
testResult = await pool.request().query(`
SELECT TOP 5
userPrincipalName,
Nom_brut,
Campus,
Contrat
FROM [GTF].[dbo].[v_Formateurs_CD]
ORDER BY Nom_brut
`);
} else if (systemStatus.hasFormateurLocal) {
testResult = await pool.request().query(` testResult = await pool.request().query(`
SELECT TOP 5 SELECT TOP 5
userPrincipalName, userPrincipalName,
@@ -953,20 +1179,41 @@ app.get('/api/formateurs-vue', async (req, res) => {
FROM formateurs_local FROM formateurs_local
ORDER BY surname, givenname ORDER BY surname, givenname
`); `);
}
if (testResult) {
console.log('Test sans filtre:', testResult.recordset); console.log('Test sans filtre:', testResult.recordset);
console.log('Types de contrats:', [...new Set(testResult.recordset.map(f => f.Contrat))]); console.log('Types de contrats:', [...new Set(testResult.recordset.map(f => f.Contrat))]);
} }
} }
formateurs = result.recordset.map(f => ({ if (systemStatus.canAccessFormateurView) {
userPrincipalName: f.userPrincipalName, // Parser Nom_brut pour v_Formateurs_CD
displayName: f.displayName, formateurs = result.recordset.map(f => {
nom: f.surname || '', const { nom, prenom } = parseNomBrut(f.Nom_brut);
prenom: f.givenname || '',
campus: f.Campus || 'Non défini', return {
contrat: f.Contrat || '', userPrincipalName: f.userPrincipalName,
displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'})`.trim() displayName: f.Nom_brut,
})); nom: nom,
prenom: prenom,
campus: f.Campus || 'Non défini',
contrat: f.Contrat || '',
displayText: `${f.Nom_brut} (${f.Campus || 'Non défini'})`.trim()
};
});
} else {
// Structure normale pour formateurs_local
formateurs = result.recordset.map(f => ({
userPrincipalName: f.userPrincipalName,
displayName: f.displayName,
nom: f.surname || '',
prenom: f.givenname || '',
campus: f.Campus || 'Non défini',
contrat: f.Contrat || '',
displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'})`.trim()
}));
}
console.log(`${formateurs.length} formateurs CDD traités`); console.log(`${formateurs.length} formateurs CDD traités`);
@@ -986,7 +1233,8 @@ app.get('/api/formateurs-vue', async (req, res) => {
}); });
} }
}); });
// ==================== ROUTES MICROSOFT GRAPH (INCHANGÉES) ====================
// ==================== ROUTES MICROSOFT GRAPH ====================
app.post('/api/auth', async (req, res) => { app.post('/api/auth', async (req, res) => {
const { userPrincipalName } = req.body; const { userPrincipalName } = req.body;
@@ -1011,9 +1259,6 @@ app.post('/api/auth', async (req, res) => {
} }
}); });
/**
* Authentifier un utilisateur avec Microsoft Graph
*/
async function authenticateUserWithGraph(userPrincipalName, accessToken) { async function authenticateUserWithGraph(userPrincipalName, accessToken) {
try { try {
const existingUser = await pool.request() const existingUser = await pool.request()
@@ -1080,9 +1325,6 @@ async function authenticateUserWithGraph(userPrincipalName, accessToken) {
} }
} }
/**
* Route pour extraire 3 personnes du service administratif
*/
app.get('/api/admin-users', async (req, res) => { app.get('/api/admin-users', async (req, res) => {
console.log('=== Route /api/admin-users appelée (RH) ==='); console.log('=== Route /api/admin-users appelée (RH) ===');
@@ -1144,9 +1386,6 @@ app.get('/api/admin-users', async (req, res) => {
} }
}); });
/**
* Route pour obtenir tous les membres du groupe
*/
app.get('/api/group-members', async (req, res) => { app.get('/api/group-members', async (req, res) => {
console.log('=== Route /api/group-members appelée (RH) ==='); console.log('=== Route /api/group-members appelée (RH) ===');
@@ -1223,18 +1462,17 @@ app.get('/api/group-members', async (req, res) => {
} }
}); });
// Route de test à ajouter temporairement
app.get('/api/test-permissions', async (req, res) => { app.get('/api/test-permissions', async (req, res) => {
try { try {
console.log('Test des permissions sur HP-TO-O365...'); console.log('Test des permissions sur GTF...');
// Test direct de la vue
const result = await pool.request().query(` const result = await pool.request().query(`
SELECT TOP 3 SELECT TOP 3
userPrincipalName, userPrincipalName,
displayName, Nom_brut,
Campus Campus,
FROM [HP-TO-O365].[dbo].[V_Formateurs_Augmentees] Contrat
FROM [GTF].[dbo].[v_Formateurs_CD]
`); `);
res.json({ res.json({
@@ -1637,9 +1875,6 @@ app.post('/api/login-hybrid', async (req, res) => {
} }
}); });
/**
* Route pour tester la table RH
*/
app.get('/api/rh-test', async (req, res) => { app.get('/api/rh-test', async (req, res) => {
try { try {
if (!pool) { if (!pool) {
@@ -1660,9 +1895,117 @@ app.get('/api/rh-test', async (req, res) => {
} }
}); });
/** // Routes de clôture
* Route pour lister tous les utilisateurs de la table RH app.get('/api/check-cloture', async (req, res) => {
*/ try {
const { date, campus } = req.query;
if (!date) {
return res.status(400).json({ error: 'Date requise' });
}
const moisAnnee = date.substring(0, 7);
const result = await pool.request()
.input('mois_annee', sql.VarChar, moisAnnee)
.input('campus', sql.VarChar, campus || null)
.query(`
SELECT * FROM clotures_saisie
WHERE mois_annee = @mois_annee
AND (campus = @campus OR campus IS NULL OR @campus IS NULL)
`);
res.json({
cloture: result.recordset.length > 0,
details: result.recordset[0] || null
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/clotures', async (req, res) => {
try {
const result = await pool.request().query(`
SELECT * FROM clotures_saisie
ORDER BY date_cloture DESC
`);
res.json({
success: true,
clotures: result.recordset
});
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
app.post('/api/cloturer-periode', async (req, res) => {
try {
const { mois_annee, date_debut, date_fin, campus, commentaire, email_rh } = req.body;
if (!mois_annee || !date_debut || !date_fin) {
return res.status(400).json({
success: false,
error: 'Données manquantes'
});
}
const existing = await pool.request()
.input('mois_annee', sql.VarChar, mois_annee)
.input('campus', sql.VarChar, campus || null)
.query(`
SELECT id FROM clotures_saisie
WHERE mois_annee = @mois_annee
AND (campus = @campus OR (campus IS NULL AND @campus IS NULL))
`);
if (existing.recordset.length > 0) {
return res.status(400).json({
success: false,
error: 'Cette période est déjà clôturée'
});
}
await pool.request()
.input('mois_annee', sql.VarChar, mois_annee)
.input('date_debut', sql.Date, date_debut)
.input('date_fin', sql.Date, date_fin)
.input('campus', sql.VarChar, campus || null)
.input('cloture_par', sql.VarChar, email_rh)
.input('commentaire', sql.NVarChar, commentaire || null)
.query(`
INSERT INTO clotures_saisie
(mois_annee, date_debut, date_fin, campus, cloture_par, commentaire)
VALUES (@mois_annee, @date_debut, @date_fin, @campus, @cloture_par, @commentaire)
`);
res.json({
success: true,
message: 'Période clôturée avec succès'
});
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
app.delete('/api/rouvrir-periode/:id', async (req, res) => {
try {
const { id } = req.params;
await pool.request()
.input('id', sql.Int, id)
.query('DELETE FROM clotures_saisie WHERE id = @id');
res.json({
success: true,
message: 'Période réouverte avec succès'
});
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
app.get('/api/rh-users', async (req, res) => { app.get('/api/rh-users', async (req, res) => {
try { try {
const limit = req.query.limit ? parseInt(req.query.limit) : 50; const limit = req.query.limit ? parseInt(req.query.limit) : 50;
@@ -1710,52 +2053,38 @@ async function startServer() {
console.log(`📊 Mode de fonctionnement RH: ${systemStatus.operatingMode}`); console.log(`📊 Mode de fonctionnement RH: ${systemStatus.operatingMode}`);
console.log(''); console.log('');
console.log('Routes disponibles :'); console.log('Routes disponibles :');
console.log('- GET /api/diagnostic (vérifier l\'état)'); console.log('- GET /api/diagnostic');
console.log('- POST /api/migrate (appliquer la migration)'); console.log('- POST /api/migrate');
console.log('- GET /api/test'); console.log('- GET /api/test');
console.log('- GET /api/db-test'); console.log('- GET /api/db-test');
console.log('- GET /api/get_declarations'); console.log('- GET /api/get_declarations');
console.log('- PUT /api/declarations/:id/status'); console.log('- PUT /api/declarations/:id/status');
console.log('- POST /api/exchange-token (Échange code Microsoft)'); console.log('- DELETE /api/declarations/:id');
console.log('- POST /api/auth (Microsoft Graph - legacy)'); console.log('- PUT /api/declarations/:id');
console.log('- GET /api/admin-users (3 utilisateurs administratifs)'); console.log('- POST /api/exchange-token');
console.log('- GET /api/group-members (tous les membres du groupe)');
console.log('- GET /api/formateurs-avec-declarations');
console.log('- GET /api/formateurs-vue'); console.log('- GET /api/formateurs-vue');
console.log('- GET /api/rh-test (test table RH)'); console.log('- GET /api/clotures');
console.log('- GET /api/rh-users (liste utilisateurs RH)'); console.log('- POST /api/cloturer-periode');
console.log('- DELETE /api/rouvrir-periode/:id');
console.log(''); console.log('');
switch (systemStatus.operatingMode) { switch (systemStatus.operatingMode) {
case 'new_with_view': case 'new_with_view':
console.log('✅ Système optimal - utilise la vue Formateurs'); console.log('✅ Système optimal - utilise la vue v_Formateurs_CD');
break; break;
case 'new_with_local': case 'new_with_local':
console.log('⚠️ Mode dégradé - utilise la table formateurs_local'); console.log('⚠️ Mode dégradé - utilise la table formateurs_local');
console.log('💡 Conseil: Vérifier les permissions sur HP-TO-O365');
break; break;
case 'new_email_only': case 'new_email_only':
console.log('⚠️ Mode minimal - sauvegarde par email sans détails formateurs'); console.log('⚠️ Mode minimal - sauvegarde par email sans détails formateurs');
break; break;
case 'legacy_hash': case 'legacy_hash':
console.log('🔄 Mode compatibilité - utilise l\'ancien système de hash'); console.log('🔄 Mode compatibilité - utilise l\'ancien système de hash');
console.log('💡 Conseil: Appliquer la migration avec POST /api/migrate');
break; break;
} }
if (!CLIENT_SECRET) {
console.warn('⚠️ Variable d\'environnement manquante: CLIENT_SECRET');
console.warn(' Ajoutez CLIENT_SECRET dans votre fichier .env');
} else {
console.log('✅ Configuration Microsoft OAuth OK');
console.log(` Client ID: ${CLIENT_ID}`);
console.log(` Tenant ID: ${TENANT_ID}`);
console.log(` Redirect URI: ${REDIRECT_URI}`);
}
}); });
} }
// Arrêt propre
process.on('SIGINT', async () => { process.on('SIGINT', async () => {
console.log('Arrêt du serveur RH...'); console.log('Arrêt du serveur RH...');
if (pool) { if (pool) {
@@ -1764,5 +2093,4 @@ process.on('SIGINT', async () => {
process.exit(0); process.exit(0);
}); });
// Démarrer
startServer(); startServer();

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1,11 +0,0 @@
// msalInstance.ts
import { PublicClientApplication } from "@azure/msal-browser";
import { msalConfig } from "./Authconfig";
export const msalInstance = new PublicClientApplication(msalConfig);
// Important : initialiser avant usage
(async () => {
await msalInstance.initialize();
})();

View File

@@ -0,0 +1,27 @@
# Dépendances
node_modules
# Cache et build
.vite
dist
node_modules/.vite
*.log
# Environnement
.env
.env.local
.env.*.local
# IDE
.vscode
.idea
*.swp
*.swo
# Git
.git
.gitignore
# Autres
.DS_Store
Thumbs.db

View File

@@ -5,7 +5,7 @@ export const msalConfig = {
auth: { auth: {
clientId: "cd99bbea-dcd4-4a76-a0b0-7aeb49931943", // Application (client) ID dans Azure clientId: "cd99bbea-dcd4-4a76-a0b0-7aeb49931943", // Application (client) ID dans Azure
authority: "https://login.microsoftonline.com/9840a2a0-6ae1-4688-b03d-d2ec291be0f9", // Directory (tenant) ID authority: "https://login.microsoftonline.com/9840a2a0-6ae1-4688-b03d-d2ec291be0f9", // Directory (tenant) ID
redirectUri: "http://localhost:5174" redirectUri: "https://mygtf-rh.ensup-adm.net"
}, },
cache: { cache: {
cacheLocation: "sessionStorage", cacheLocation: "sessionStorage",

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect } from 'react';
import { Lock, Unlock, Calendar, AlertCircle } from 'lucide-react';
interface Cloture {
id: number;
mois_annee: string;
date_debut: string;
date_fin: string;
campus: string | null;
cloture_par: string;
date_cloture: string;
commentaire: string | null;
}
const ClotureManager: React.FC = () => {
const [clotures, setClotures] = useState<Cloture[]>([]);
const [selectedMonth, setSelectedMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
});
const [selectedCampus, setSelectedCampus] = useState<string>('all');
const [commentaire, setCommentaire] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const campuses = ['Cergy', 'Nantes', 'SQY', 'Marseille'];
const loadClotures = async () => {
try {
const response = await fetch('/api/clotures');
const data = await response.json();
if (data.success) {
setClotures(data.clotures);
}
} catch (err) {
console.error('Erreur chargement clôtures:', err);
}
};
useEffect(() => {
loadClotures();
}, []);
const handleCloturer = async () => {
try {
setLoading(true);
setError('');
const [year, month] = selectedMonth.split('-');
const dateDebutStr = `${year}-${month}-01`;
const lastDay = new Date(parseInt(year), parseInt(month), 0).getDate();
const dateFinStr = `${year}-${month}-${String(lastDay).padStart(2, '0')}`;
const response = await fetch('/api/cloturer-periode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mois_annee: selectedMonth,
date_debut: dateDebutStr,
date_fin: dateFinStr,
campus: selectedCampus === 'all' ? null : selectedCampus,
commentaire
})
});
const data = await response.json();
if (data.success) {
alert('Période clôturée avec succès');
setCommentaire('');
loadClotures();
} else {
setError(data.error || 'Erreur lors de la clôture');
}
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleRouvrir = async (id: number) => {
if (!confirm('Voulez-vous vraiment rouvrir cette période ?')) return;
try {
const response = await fetch(`/api/rouvrir-periode/${id}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
alert('Période réouverte avec succès');
loadClotures();
}
} catch (err) {
console.error('Erreur:', err);
}
};
const formatDateSQL = (dateString: string) => {
if (!dateString) return 'Date inconnue';
try {
let dateOnly = dateString;
if (dateString.includes('T')) {
dateOnly = dateString.split('T')[0];
}
const [year, month, day] = dateOnly.split('-').map(num => parseInt(num, 10));
const date = new Date(Date.UTC(year, month - 1, day));
if (isNaN(date.getTime())) {
return 'Date invalide';
}
return date.toLocaleDateString('fr-FR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'UTC'
});
} catch (error) {
console.error('Erreur formatage date:', dateString, error);
return 'Date invalide';
}
};
return (
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
<div className="flex items-center gap-2 mb-6">
<Lock className="text-red-600" size={24} />
<h2 className="text-xl font-semibold text-gray-800">
Gestion des clôtures de saisie
</h2>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<div className="flex items-center gap-2">
<AlertCircle className="text-red-600" size={20} />
<span className="text-red-800">{error}</span>
</div>
</div>
)}
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="font-medium text-gray-800 mb-4">
Clôturer une nouvelle période
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Mois à clôturer
</label>
<input
type="month"
value={selectedMonth}
onChange={(e) => setSelectedMonth(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-4 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Campus
</label>
<select
value={selectedCampus}
onChange={(e) => setSelectedCampus(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-4 py-2"
>
<option value="all">Tous les campus</option>
{campuses.map(campus => (
<option key={campus} value={campus}>{campus}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Commentaire (optionnel)
</label>
<input
type="text"
value={commentaire}
onChange={(e) => setCommentaire(e.target.value)}
placeholder="Ex: Clôture mensuelle"
className="w-full border border-gray-300 rounded-lg px-4 py-2"
/>
</div>
</div>
<button
onClick={handleCloturer}
disabled={loading}
className="w-full bg-red-600 hover:bg-red-700 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center gap-2 disabled:opacity-50"
>
<Lock size={20} />
{loading ? 'Clôture en cours...' : 'Clôturer cette période'}
</button>
</div>
<div>
<h3 className="font-medium text-gray-800 mb-4">
Périodes clôturées ({clotures.length})
</h3>
{clotures.length === 0 ? (
<p className="text-gray-500 text-center py-4">
Aucune période clôturée
</p>
) : (
<div className="space-y-2">
{clotures.map(cloture => (
<div
key={cloture.id}
className="flex items-center justify-between p-4 bg-red-50 border border-red-200 rounded-lg"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<Calendar size={16} className="text-red-600" />
<span className="font-medium text-gray-800">
{formatDateSQL(cloture.date_debut)}
{' → '}
{formatDateSQL(cloture.date_fin)}
</span>
<span className="text-sm text-gray-600">
({cloture.mois_annee})
</span>
</div>
<div className="text-sm text-gray-600 mt-1">
Campus: {cloture.campus || 'Tous'}
Clôturé le {formatDateSQL(cloture.date_cloture)}
{cloture.commentaire && `${cloture.commentaire}`}
</div>
</div>
<button
onClick={() => handleRouvrir(cloture.id)}
className="ml-4 px-4 py-2 bg-white hover:bg-gray-50 text-red-600 border border-red-300 rounded-lg flex items-center gap-2"
>
<Unlock size={16} />
Rouvrir
</button>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default ClotureManager;

View File

@@ -1,204 +1,95 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from "react";
import { useAuth } from '../context/AuthContext'; import { useAuth } from "../context/AuthContext";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { AlertTriangle, Users } from 'lucide-react'; import { AlertTriangle } from "lucide-react";
const Login = () => { const Login = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState("");
const navigate = useNavigate(); const navigate = useNavigate();
const { loginWithO365, isAuthorized } = useAuth(); const { loginWithO365 } = useAuth();
// Configuration du backend Node.js
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3002';
// Redirection si déjà connecté
useEffect(() => {
if (isAuthorized) {
navigate('/dashboard');
}
}, [isAuthorized, navigate]);
const handleO365Login = async () => { const handleO365Login = async () => {
setIsLoading(true); setIsLoading(true);
setError(''); setError("");
try { try {
// 1. Connexion Office 365 via votre contexte existant
const success = await loginWithO365(); const success = await loginWithO365();
if (!success) { if (!success) {
setError("Erreur lors de l'initialisation de la connexion Office 365"); setError("Erreur lors de la connexion Office 365");
setIsLoading(false); } else {
return; navigate("/dashboard");
} }
// Le reste sera géré par l'AuthContext lors du retour de Microsoft
// (exchangeCodeForToken + vérification backend)
} catch (err: any) { } catch (err: any) {
console.error('Erreur O365:', err);
setError(err.message || "Erreur lors de la connexion Office 365"); setError(err.message || "Erreur lors de la connexion Office 365");
} finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// Fonction pour traiter l'authentification complète (appelée après retour de Microsoft)
const completeAuthentication = async () => {
try {
// 2. Récupération du token et des infos utilisateur
const token = localStorage.getItem("o365_token");
const userEmail = localStorage.getItem("user_email");
if (!token || !userEmail) {
setError("Token ou email utilisateur manquant");
return;
}
// 3. Vérification de l'autorisation via votre backend Node.js
const authResponse = await fetch(`${BACKEND_URL}/api/check-user-groups`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
userPrincipalName: userEmail
})
});
if (!authResponse.ok) {
throw new Error(`Erreur serveur: ${authResponse.status}`);
}
const authData = await authResponse.json();
console.log("Résultat autorisation backend :", authData);
if (!authData.authorized) {
setError(authData.message || "Utilisateur non autorisé");
return;
}
// 4. Stocker les informations utilisateur complémentaires
localStorage.setItem("user_data", JSON.stringify(authData.user));
localStorage.setItem("user_role", authData.role);
localStorage.setItem("local_user_id", authData.localUserId.toString());
console.log("Utilisateur autorisé :", authData.user);
// 5. Optionnel : Déclencher une synchronisation initiale si c'est le premier login
try {
const syncResponse = await fetch(`${BACKEND_URL}/api/initial-sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (syncResponse.ok) {
const syncData = await syncResponse.json();
console.log("Synchronisation terminée :", syncData);
}
} catch (syncError) {
console.warn("Erreur synchronisation (non bloquante):", syncError);
// Ne pas bloquer la connexion pour une erreur de sync
}
// 6. Redirection vers dashboard
navigate('/dashboard');
} catch (err: any) {
console.error('Erreur lors de la finalisation de l\'authentification:', err);
// Gestion des erreurs spécifiques
if (err.message?.includes('non autorisé') || err.message?.includes('Accès refusé')) {
setError('Accès refusé : Vous devez être membre du groupe autorisé dans votre organisation.');
} else if (err.message?.includes('AADSTS')) {
setError('Erreur d\'authentification Azure AD. Contactez votre administrateur.');
} else if (err.message?.includes('fetch')) {
setError('Impossible de contacter le serveur. Vérifiez que le backend est démarré.');
} else {
setError(err.message || "Erreur lors de la finalisation de la connexion");
}
}
};
// Vérifier s'il faut finaliser l'authentification au chargement
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const authToken = localStorage.getItem('o365_token');
// Si on a un code ET un token (authentification en cours), finaliser
if (code && authToken && !isAuthorized) {
setIsLoading(true);
completeAuthentication().finally(() => setIsLoading(false));
}
}, [isAuthorized]);
return ( return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4"> <div className="min-h-screen flex">
<div className="max-w-md w-full"> {/* Colonne gauche avec image + overlay */}
<div className="bg-white rounded-lg shadow-md p-8"> <div className="hidden md:flex w-1/2 relative">
<img
src="/backend/images/Ensup.png"
alt="ENSUP"
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#6d4a91]/80 to-[#7e5aa2]/40 flex flex-col justify-center items-center text-white p-8">
<h1 className="text-3xl font-bold mb-2">Bienvenue chez ENSUP</h1>
<p className="text-lg text-gray-200">
Espace RH - Gestion et Connexion Sécurisée
</p>
</div>
</div>
{/* En-tête */} {/* Colonne droite avec le login */}
<div className="flex w-full md:w-1/2 items-center justify-center p-8 bg-gray-50">
<div className="w-full max-w-md bg-white shadow-xl rounded-2xl p-8">
<div className="text-center mb-8"> <div className="text-center mb-8">
<div className="w-16 h-16 bg-[#7e5aa2] rounded-lg flex items-center justify-center mx-auto mb-4"> <h2 className="text-2xl font-bold text-gray-900">
<Users className="w-8 h-8 text-white" /> Connexion à lespace RH
</div> </h2>
<h1 className="text-2xl font-bold text-gray-900 mb-2">GTF - Espace RH</h1> <p className="text-gray-600 mt-2">
<p className="text-gray-600 text-sm"> Utilisez votre compte Office 365
Connectez-vous avec votre compte Office 365
</p> </p>
</div> </div>
{/* Bouton de connexion Office 365 */} {/* Bouton violet ENSUP */}
<button <button
onClick={handleO365Login} onClick={handleO365Login}
disabled={isLoading} disabled={isLoading}
className="w-full bg-[#7e5aa2] hover:bg-[#6d4a91] text-white py-3 px-4 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2" className="w-full bg-[#7e5aa2] hover:bg-[#6d4a91] text-white py-3 px-4 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2 shadow-md disabled:opacity-50"
> >
{isLoading ? ( {isLoading ? (
<> <>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div> <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Connexion en cours...</span> Connexion en cours...
</> </>
) : ( ) : (
<> <>Se connecter avec Office 365</>
<span>Se connecter avec Office 365</span>
</>
)} )}
</button> </button>
{/* Message d'erreur */} {/* Message d'erreur */}
{error && ( {error && (
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg"> <div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2">
<div className="flex items-start space-x-2"> <AlertTriangle className="w-5 h-5 text-red-500 mt-0.5" />
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" /> <div>
<div> <p className="text-red-800 font-medium text-sm">
<p className="text-red-800 font-medium text-sm"> Erreur de connexion
{error.includes('Accès refusé') ? 'Accès refusé' : </p>
error.includes('serveur') ? 'Problème de connexion' : <p className="text-red-700 text-xs mt-1">{error}</p>
'Erreur de connexion'}
</p>
<p className="text-red-700 text-xs mt-1">{error}</p>
{error.includes('groupe autorisé') && (
<p className="text-red-700 text-xs mt-2">
Contactez votre administrateur pour être ajouté au groupe GTF.
</p>
)}
{error.includes('serveur') && (
<p className="text-red-700 text-xs mt-2">
Vérifiez que le serveur backend est démarré sur le port 3002.
</p>
)}
</div>
</div> </div>
</div> </div>
)} )}
{/* Footer */}
<p className="text-center text-xs text-gray-500 mt-6">
© {new Date().getFullYear()} ENSUP / ENSITECH. Tous droits réservés.
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,20 +1,22 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Users, Download, Calendar, Clock, FileText, Filter, LogOut, RefreshCw, AlertCircle } from 'lucide-react'; import { Users, Download, Calendar, Clock, FileText, Filter, LogOut, RefreshCw, Lock, Edit2, Trash2, X, Save } from 'lucide-react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import ClotureManager from '../components/CloturePeriode';
interface TimeEntry { interface TimeEntry {
id: string; id: string;
formateur: string; formateur: string;
campus: string; campus: string;
date: string; date: string;
type: 'preparation' | 'correction'; type: 'preparation';
hours: number; hours: number;
description: string; description: string;
status: 'pending' | 'approved' | 'rejected'; status: 'pending' | 'approved' | 'rejected';
heure_debut?: string; heure_debut?: string;
heure_fin?: string; heure_fin?: string;
formateur_numero?: number; formateur_numero?: number;
formateur_email?: string; // NOUVEAU CHAMP formateur_email?: string;
type_demande_id?: number;
} }
interface FormateurAvecDeclarations { interface FormateurAvecDeclarations {
@@ -42,7 +44,8 @@ const RHDashboard: React.FC = () => {
const month = String(now.getMonth() + 1).padStart(2, '0'); const month = String(now.getMonth() + 1).padStart(2, '0');
return `${year}-${month}`; return `${year}-${month}`;
}); });
const { logout } = useAuth(); const [showClotureManager, setShowClotureManager] = useState(false);
const { logout, user } = useAuth();
const [selectedFormateur, setSelectedFormateur] = useState<string>('all'); const [selectedFormateur, setSelectedFormateur] = useState<string>('all');
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]); const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
const [formateursAvecDeclarations, setFormateursAvecDeclarations] = useState<FormateurAvecDeclarations[]>([]); const [formateursAvecDeclarations, setFormateursAvecDeclarations] = useState<FormateurAvecDeclarations[]>([]);
@@ -51,54 +54,34 @@ const RHDashboard: React.FC = () => {
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null); const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
// Fonction pour normaliser/mapper les noms de campus const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
const [showEditModal, setShowEditModal] = useState(false);
const [editForm, setEditForm] = useState({
date: '',
hours: 0,
heure_debut: '',
heure_fin: '',
description: '',
type: 'preparation' as 'preparation'
});
const normalizeCampus = (campus: string): string => { const normalizeCampus = (campus: string): string => {
const mapping: { [key: string]: string } = { const mapping: { [key: string]: string } = {
// Codes courts vers noms longs 'MRS': 'Marseille', 'mrs': 'Marseille',
'MRS': 'Marseille', 'NTE': 'Nantes', 'nte': 'Nantes',
'mrs': 'Marseille', 'CGY': 'Cergy', 'cgy': 'Cergy',
'NTE': 'Nantes', 'SQY': 'SQY', 'sqy': 'SQY', 'ensqy': 'SQY',
'nte': 'Nantes', 'SQY/CGY': 'SQY/CGY', 'sqy/cgy': 'SQY/CGY',
'CGY': 'Cergy', 'Marseille': 'Marseille', 'Nantes': 'Nantes', 'Cergy': 'Cergy',
'cgy': 'Cergy',
'SQY': 'SQY',
'sqy': 'SQY',
'ensqy': 'SQY',
'SQY/CGY': 'SQY/CGY', // Campus multi-sites
'sqy/cgy': 'SQY/CGY',
// Noms complets (au cas où)
'Marseille': 'Marseille',
'Nantes': 'Nantes',
'Cergy': 'Cergy',
'Saint-Quentin-en-Yvelines': 'SQY', 'Saint-Quentin-en-Yvelines': 'SQY',
'MARSEILLE': 'Marseille', 'MARSEILLE': 'Marseille', 'NANTES': 'Nantes', 'CERGY': 'Cergy',
'NANTES': 'Nantes', 'Non défini': 'Non défini', '': 'Non défini'
'CERGY': 'Cergy',
// Fallback
'Non défini': 'Non défini',
'': 'Non défini'
}; };
return mapping[campus] || campus || 'Non défini'; return mapping[campus] || campus || 'Non défini';
}; };
// Fonction inversée pour obtenir le code depuis le label
const getCampusCode = (label: string): string => {
const reverseMapping: { [key: string]: string } = {
'Marseille': 'MRS',
'Nantes': 'NTE',
'Cergy': 'CGY',
'SQY': 'SQY',
'SQY/CGY': 'SQY/CGY'
};
return reverseMapping[label] || label;
};
// Liste des campus pour l'interface (incluant SQY/CGY comme cas spécial)
const campuses = ['Cergy', 'Nantes', 'SQY', 'Marseille', 'SQY/CGY']; const campuses = ['Cergy', 'Nantes', 'SQY', 'Marseille', 'SQY/CGY'];
// Fonction pour générer le hash (même logique que le backend - pour compatibilité)
const generateHashFromEmail = (email: string): number => { const generateHashFromEmail = (email: string): number => {
let hash = 0; let hash = 0;
for (let i = 0; i < email.length; i++) { for (let i = 0; i < email.length; i++) {
@@ -109,10 +92,9 @@ const RHDashboard: React.FC = () => {
return Math.abs(hash) % 10000 + 1000; return Math.abs(hash) % 10000 + 1000;
}; };
// Fonction pour récupérer le statut du système
const loadSystemStatus = async () => { const loadSystemStatus = async () => {
try { try {
const response = await fetch('http://localhost:3002/api/diagnostic'); const response = await fetch('/api/diagnostic');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setSystemStatus(data.systemStatus); setSystemStatus(data.systemStatus);
@@ -123,11 +105,9 @@ const RHDashboard: React.FC = () => {
} }
}; };
// Fonction améliorée pour associer les déclarations avec les formateurs
const getFormateurInfo = (declaration: any): { nom: string, prenom: string, campus: string, displayText: string } => { const getFormateurInfo = (declaration: any): { nom: string, prenom: string, campus: string, displayText: string } => {
console.log('🔍 Recherche formateur pour:', declaration); console.log('🔍 Recherche formateur pour:', declaration);
// NOUVEAU SYSTÈME : Si on a un email, chercher le formateur correspondant
if (declaration.formateur_email || declaration.formateur_email_fk) { if (declaration.formateur_email || declaration.formateur_email_fk) {
const email = declaration.formateur_email || declaration.formateur_email_fk; const email = declaration.formateur_email || declaration.formateur_email_fk;
const formateurTrouve = formateursAvecDeclarations.find(f => const formateurTrouve = formateursAvecDeclarations.find(f =>
@@ -139,13 +119,12 @@ const RHDashboard: React.FC = () => {
return { return {
nom: formateurTrouve.nom, nom: formateurTrouve.nom,
prenom: formateurTrouve.prenom, prenom: formateurTrouve.prenom,
campus: formateurTrouve.campus, // Garder le campus original de la vue campus: formateurTrouve.campus,
displayText: formateurTrouve.displayText displayText: formateurTrouve.displayText
}; };
} }
} }
// FALLBACK : Si on a les champs nom/prenom/campus directement dans la déclaration (nouveau système)
if (declaration.nom && declaration.nom !== 'undefined') { if (declaration.nom && declaration.nom !== 'undefined') {
console.log(`✅ Formateur trouvé dans les données déclaration: ${declaration.nom} ${declaration.prenom} (${declaration.campus})`); console.log(`✅ Formateur trouvé dans les données déclaration: ${declaration.nom} ${declaration.prenom} (${declaration.campus})`);
return { return {
@@ -156,11 +135,8 @@ const RHDashboard: React.FC = () => {
}; };
} }
// ANCIEN SYSTÈME : Si on a un formateur_numero, essayer de le correspondre
if (declaration.formateur_numero && formateursAvecDeclarations.length > 0) { if (declaration.formateur_numero && formateursAvecDeclarations.length > 0) {
console.log(`🔍 Recherche par hash pour numéro: ${declaration.formateur_numero}`); console.log(`🔍 Recherche par hash pour numéro: ${declaration.formateur_numero}`);
// Méthode 1: Chercher par hash d'email
const formateurParHash = formateursAvecDeclarations.find(f => { const formateurParHash = formateursAvecDeclarations.find(f => {
if (f.userPrincipalName) { if (f.userPrincipalName) {
const hash = generateHashFromEmail(f.userPrincipalName); const hash = generateHashFromEmail(f.userPrincipalName);
@@ -180,7 +156,6 @@ const RHDashboard: React.FC = () => {
} }
} }
// DERNIER RECOURS : Mapping connu pour les cas difficiles
const knownMappings: { [key: number]: { nom: string, prenom: string, campus: string } } = { const knownMappings: { [key: number]: { nom: string, prenom: string, campus: string } } = {
122: { nom: 'Admin', prenom: 'Ensup', campus: 'SQY' }, 122: { nom: 'Admin', prenom: 'Ensup', campus: 'SQY' },
999: { nom: 'Inconnu', prenom: 'Formateur', campus: 'Non défini' } 999: { nom: 'Inconnu', prenom: 'Formateur', campus: 'Non défini' }
@@ -197,7 +172,6 @@ const RHDashboard: React.FC = () => {
}; };
} }
// FALLBACK FINAL
const identifier = declaration.formateur_email || declaration.formateur_numero || 'Inconnu'; const identifier = declaration.formateur_email || declaration.formateur_numero || 'Inconnu';
console.log(`⚠️ Aucune correspondance trouvée, utilisation du fallback pour: ${identifier}`); console.log(`⚠️ Aucune correspondance trouvée, utilisation du fallback pour: ${identifier}`);
return { return {
@@ -208,16 +182,13 @@ const RHDashboard: React.FC = () => {
}; };
}; };
// Fonction pour charger TOUS les formateurs (avec et sans déclarations)
const loadFormateursAvecDeclarations = async () => { const loadFormateursAvecDeclarations = async () => {
try { try {
setLoadingFormateurs(true); setLoadingFormateurs(true);
setError(''); setError('');
console.log('🔄 Chargement de tous les formateurs...'); console.log('🔄 Chargement de tous les formateurs...');
// Essayer d'abord de récupérer tous les formateurs depuis la vue const response = await fetch('/api/formateurs-vue');
const response = await fetch('http://localhost:3002/api/formateurs-vue');
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
@@ -225,7 +196,6 @@ const RHDashboard: React.FC = () => {
console.log(`${data.formateurs.length} formateurs chargés depuis la vue`); console.log(`${data.formateurs.length} formateurs chargés depuis la vue`);
console.log('📊 Mode serveur:', data.mode); console.log('📊 Mode serveur:', data.mode);
// Convertir le format et initialiser à 0 déclarations
const tousLesFormateurs = data.formateurs.map((f: any) => ({ const tousLesFormateurs = data.formateurs.map((f: any) => ({
...f, ...f,
nbDeclarations: 0, nbDeclarations: 0,
@@ -242,16 +212,15 @@ const RHDashboard: React.FC = () => {
} catch (error: any) { } catch (error: any) {
console.error('❌ Erreur chargement formateurs depuis la vue:', error); console.error('❌ Erreur chargement formateurs depuis la vue:', error);
// Fallback: essayer avec formateurs-avec-declarations (ancienne méthode)
try { try {
console.log('🔄 Tentative de fallback avec formateurs-avec-declarations...'); console.log('🔄 Tentative de fallback avec formateurs-avec-declarations...');
const fallbackResponse = await fetch('http://localhost:3002/api/formateurs-avec-declarations'); const fallbackResponse = await fetch('/api/formateurs-avec-declarations');
if (fallbackResponse.ok) { if (fallbackResponse.ok) {
const fallbackData = await fallbackResponse.json(); const fallbackData = await fallbackResponse.json();
if (fallbackData.success) { if (fallbackData.success) {
setFormateursAvecDeclarations(fallbackData.formateurs); setFormateursAvecDeclarations(fallbackData.formateurs);
console.log('🔄 Fallback réussi:', fallbackData.formateurs.length, 'formateurs avec déclarations'); console.log('🔄 Fallback réussi:', fallbackData.formateurs.length, 'formateurs avec déclarations');
setError(''); // Effacer l'erreur si le fallback fonctionne setError('');
} }
} }
} catch (fallbackError) { } catch (fallbackError) {
@@ -263,14 +232,13 @@ const RHDashboard: React.FC = () => {
} }
}; };
// Charger les déclarations depuis votre API
const loadDeclarations = async () => { const loadDeclarations = async () => {
try { try {
setLoading(true); setLoading(true);
setError(''); setError('');
console.log('🔄 Chargement des déclarations...'); console.log('🔄 Chargement des déclarations...');
const response = await fetch('http://localhost:3002/api/get_declarations'); const response = await fetch('/api/get_declarations');
if (!response.ok) { if (!response.ok) {
throw new Error(`Erreur HTTP ${response.status} lors du chargement des déclarations`); throw new Error(`Erreur HTTP ${response.status} lors du chargement des déclarations`);
@@ -280,30 +248,29 @@ const RHDashboard: React.FC = () => {
console.log('📊 Données déclarations reçues:', data.length); console.log('📊 Données déclarations reçues:', data.length);
console.log('📊 Exemple de déclaration:', data[0]); console.log('📊 Exemple de déclaration:', data[0]);
// Convertir les données (avec normalisation des campus)
const convertedEntries: TimeEntry[] = data.map((d: any) => { const convertedEntries: TimeEntry[] = data.map((d: any) => {
const formateurInfo = getFormateurInfo(d); const formateurInfo = getFormateurInfo(d);
return { return {
id: d.id.toString(), id: d.id.toString(),
formateur: formateurInfo.displayText, formateur: formateurInfo.displayText,
campus: normalizeCampus(formateurInfo.campus), // Normaliser le campus campus: normalizeCampus(formateurInfo.campus),
date: d.date.split('T')[0], date: d.date.split('T')[0],
type: d.activityType, type: 'preparation',
hours: d.duree, hours: d.duree,
description: d.description || '', description: d.description || '',
status: d.status || 'pending', status: d.status || 'pending',
heure_debut: d.heure_debut || null, heure_debut: d.heure_debut || null,
heure_fin: d.heure_fin || null, heure_fin: d.heure_fin || null,
formateur_numero: d.formateur_numero, formateur_numero: d.formateur_numero,
formateur_email: d.formateur_email || d.formateur_email_fk formateur_email: d.formateur_email || d.formateur_email_fk,
type_demande_id: d.type_demande_id
}; };
}); });
setTimeEntries(convertedEntries); setTimeEntries(convertedEntries);
console.log(`${convertedEntries.length} déclarations traitées`); console.log(`${convertedEntries.length} déclarations traitées`);
// Log des formateurs uniques pour debug
const formateursUniques = [...new Set(convertedEntries.map(e => e.formateur))]; const formateursUniques = [...new Set(convertedEntries.map(e => e.formateur))];
console.log(`📊 ${formateursUniques.length} formateurs uniques dans les déclarations:`, formateursUniques.slice(0, 5)); console.log(`📊 ${formateursUniques.length} formateurs uniques dans les déclarations:`, formateursUniques.slice(0, 5));
@@ -315,14 +282,12 @@ const RHDashboard: React.FC = () => {
} }
}; };
// Charger les données au démarrage
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
await loadSystemStatus(); await loadSystemStatus();
await loadFormateursAvecDeclarations(); await loadFormateursAvecDeclarations();
await loadDeclarations(); await loadDeclarations();
// Debug: Afficher tous les campus uniques trouvés dans les données
setTimeout(() => { setTimeout(() => {
const campusUniques = [...new Set(formateursAvecDeclarations.map(f => f.campus))].filter(Boolean); const campusUniques = [...new Set(formateursAvecDeclarations.map(f => f.campus))].filter(Boolean);
const campusDeclarations = [...new Set(timeEntries.map(e => e.campus))].filter(Boolean); const campusDeclarations = [...new Set(timeEntries.map(e => e.campus))].filter(Boolean);
@@ -335,12 +300,10 @@ const RHDashboard: React.FC = () => {
loadData(); loadData();
}, []); }, []);
// Re-traiter les déclarations quand les formateurs sont chargés ET calculer les nombres de déclarations
useEffect(() => { useEffect(() => {
if (formateursAvecDeclarations.length > 0) { if (formateursAvecDeclarations.length > 0) {
console.log('🔄 Re-traitement des déclarations avec les nouveaux formateurs...'); console.log('🔄 Re-traitement des déclarations avec les nouveaux formateurs...');
// Si on a déjà des déclarations, les retraiter
if (timeEntries.length > 0) { if (timeEntries.length > 0) {
const updatedEntries = timeEntries.map(entry => { const updatedEntries = timeEntries.map(entry => {
const originalDeclaration = { const originalDeclaration = {
@@ -359,21 +322,17 @@ const RHDashboard: React.FC = () => {
setTimeEntries(updatedEntries); setTimeEntries(updatedEntries);
} }
// Calculer le nombre de déclarations pour chaque formateur
const formateursAvecCompte = formateursAvecDeclarations.map(formateur => { const formateursAvecCompte = formateursAvecDeclarations.map(formateur => {
const nbDeclarations = timeEntries.filter(entry => { const nbDeclarations = timeEntries.filter(entry => {
// Correspondance par email
if (entry.formateur_email === formateur.userPrincipalName) { if (entry.formateur_email === formateur.userPrincipalName) {
return true; return true;
} }
// Correspondance par hash
if (entry.formateur_numero && formateur.userPrincipalName) { if (entry.formateur_numero && formateur.userPrincipalName) {
const hash = generateHashFromEmail(formateur.userPrincipalName); const hash = generateHashFromEmail(formateur.userPrincipalName);
return hash === entry.formateur_numero; return hash === entry.formateur_numero;
} }
// Correspondance par nom affiché
const expectedDisplayText = `${formateur.nom} ${formateur.prenom} (${formateur.campus})`.trim(); const expectedDisplayText = `${formateur.nom} ${formateur.prenom} (${formateur.campus})`.trim();
return entry.formateur === expectedDisplayText || entry.formateur === formateur.displayText; return entry.formateur === expectedDisplayText || entry.formateur === formateur.displayText;
}).length; }).length;
@@ -386,19 +345,15 @@ const RHDashboard: React.FC = () => {
setFormateursAvecDeclarations(formateursAvecCompte); setFormateursAvecDeclarations(formateursAvecCompte);
} }
}, [timeEntries.length]); // Déclenché quand le nombre de déclarations change }, [timeEntries.length]);
// Réinitialiser le filtre formateur quand on change de campus
useEffect(() => { useEffect(() => {
// Si un formateur est sélectionné et qu'on change de campus
if (selectedFormateur !== 'all') { if (selectedFormateur !== 'all') {
// Vérifier si le formateur sélectionné existe encore dans le campus filtré
const formateurExists = formateursAvecDeclarations.some(formateur => { const formateurExists = formateursAvecDeclarations.some(formateur => {
const campusMatch = selectedCampus === 'all' || formateur.campus === selectedCampus; const campusMatch = selectedCampus === 'all' || formateur.campus === selectedCampus;
return campusMatch && formateur.displayText === selectedFormateur; return campusMatch && formateur.displayText === selectedFormateur;
}); });
// Si le formateur n'existe pas dans le nouveau campus, le réinitialiser
if (!formateurExists) { if (!formateurExists) {
console.log(`🔄 Réinitialisation du filtre formateur (${selectedFormateur} n'est pas dans ${selectedCampus})`); console.log(`🔄 Réinitialisation du filtre formateur (${selectedFormateur} n'est pas dans ${selectedCampus})`);
setSelectedFormateur('all'); setSelectedFormateur('all');
@@ -406,7 +361,6 @@ const RHDashboard: React.FC = () => {
} }
}, [selectedCampus, formateursAvecDeclarations]); }, [selectedCampus, formateursAvecDeclarations]);
// Fonction de déconnexion
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('o365_token'); localStorage.removeItem('o365_token');
@@ -415,14 +369,83 @@ const RHDashboard: React.FC = () => {
window.location.href = '/login'; window.location.href = '/login';
}; };
// Fonction de rafraîchissement complète
const handleRefresh = async () => { const handleRefresh = async () => {
await loadSystemStatus(); await loadSystemStatus();
await loadFormateursAvecDeclarations(); await loadFormateursAvecDeclarations();
await loadDeclarations(); await loadDeclarations();
}; };
// Filtrage des données (avec normalisation des campus) const handleEdit = (entry: TimeEntry) => {
setEditingEntry(entry);
setEditForm({
date: entry.date,
hours: entry.hours,
heure_debut: entry.heure_debut || '',
heure_fin: entry.heure_fin || '',
description: entry.description,
type: entry.type
});
setShowEditModal(true);
};
const handleSaveEdit = async () => {
if (!editingEntry) return;
try {
const type_demande_id = editingEntry.type_demande_id || 1;
console.log('Mise à jour avec type_demande_id:', type_demande_id);
const response = await fetch(`/api/declarations/${editingEntry.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: editForm.date,
duree: editForm.hours,
heure_debut: editForm.heure_debut || null,
heure_fin: editForm.heure_fin || null,
description: editForm.description,
type_demande_id: type_demande_id
})
});
const result = await response.json();
if (result.success) {
setShowEditModal(false);
setEditingEntry(null);
await loadDeclarations();
} else {
alert('Erreur: ' + result.message);
}
} catch (error: any) {
console.error('Erreur lors de la modification:', error);
alert('Erreur lors de la modification: ' + error.message);
}
};
const handleDelete = async (entry: TimeEntry) => {
if (!confirm(`Êtes-vous sûr de vouloir supprimer cette déclaration de ${entry.formateur} du ${new Date(entry.date).toLocaleDateString('fr-FR')} ?`)) {
return;
}
try {
const response = await fetch(`/api/declarations/${entry.id}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
await loadDeclarations();
} else {
alert('Erreur: ' + result.message);
}
} catch (error: any) {
alert('Erreur lors de la suppression: ' + error.message);
}
};
const filteredEntries = timeEntries.filter(entry => { const filteredEntries = timeEntries.filter(entry => {
const entryNormalizedCampus = normalizeCampus(entry.campus); const entryNormalizedCampus = normalizeCampus(entry.campus);
const campusMatch = selectedCampus === 'all' || entryNormalizedCampus === selectedCampus; const campusMatch = selectedCampus === 'all' || entryNormalizedCampus === selectedCampus;
@@ -431,75 +454,60 @@ const RHDashboard: React.FC = () => {
return campusMatch && formateurMatch && monthMatch; return campusMatch && formateurMatch && monthMatch;
}); });
// Générer la liste des formateurs filtrés par campus (avec déduplication)
const formateursUniques = formateursAvecDeclarations const formateursUniques = formateursAvecDeclarations
.filter(formateur => { .filter(formateur => {
// Si "tous les campus" est sélectionné, afficher tous les formateurs
if (selectedCampus === 'all') return true; if (selectedCampus === 'all') return true;
// Normaliser le campus du formateur et comparer avec le campus sélectionné
const formateurCampusNormalized = normalizeCampus(formateur.campus); const formateurCampusNormalized = normalizeCampus(formateur.campus);
return formateurCampusNormalized === selectedCampus; return formateurCampusNormalized === selectedCampus;
}) })
.reduce((acc: any[], formateur) => { .reduce((acc: any[], formateur) => {
// Déduplicquer par email (userPrincipalName)
const existing = acc.find(f => f.userPrincipalName === formateur.userPrincipalName); const existing = acc.find(f => f.userPrincipalName === formateur.userPrincipalName);
if (!existing) { if (!existing) {
acc.push({ acc.push({
displayText: formateur.displayText, displayText: formateur.displayText,
nbDeclarations: formateur.nbDeclarations || 0, nbDeclarations: formateur.nbDeclarations || 0,
userPrincipalName: formateur.userPrincipalName, userPrincipalName: formateur.userPrincipalName,
campus: normalizeCampus(formateur.campus) // Normaliser le campus pour l'affichage campus: normalizeCampus(formateur.campus)
}); });
} else { } else {
// Si le formateur existe déjà, additionner les déclarations
existing.nbDeclarations += (formateur.nbDeclarations || 0); existing.nbDeclarations += (formateur.nbDeclarations || 0);
} }
return acc; return acc;
}, []) }, [])
.sort((a, b) => a.displayText.localeCompare(b.displayText)); .sort((a, b) => a.displayText.localeCompare(b.displayText));
// Statistiques par campus (avec normalisation)
const getStatsForCampus = (campus: string) => { const getStatsForCampus = (campus: string) => {
const campusEntries = timeEntries.filter(entry => { const campusEntries = timeEntries.filter(entry => {
const entryNormalizedCampus = normalizeCampus(entry.campus); const entryNormalizedCampus = normalizeCampus(entry.campus);
return entryNormalizedCampus === campus; return entryNormalizedCampus === campus;
}); });
const totalHours = campusEntries.reduce((sum, entry) => sum + entry.hours, 0); const totalHours = campusEntries.reduce((sum, entry) => sum + entry.hours, 0);
const preparationHours = campusEntries
.filter(entry => entry.type === 'preparation')
.reduce((sum, entry) => sum + entry.hours, 0);
const correctionHours = campusEntries
.filter(entry => entry.type === 'correction')
.reduce((sum, entry) => sum + entry.hours, 0);
return { totalHours, preparationHours, correctionHours }; return { totalHours };
}; };
const handleExport = () => { const handleExport = () => {
const csvHeaders = ['Date', 'Formateur', 'Campus', 'Type', 'Heures', 'Heure Début', 'Heure Fin', 'Description']; const csvHeaders = ['Date', 'Formateur', 'Campus', 'Heures', 'Heure Début', 'Heure Fin', 'Description'];
const csvData = filteredEntries.map(entry => [ const csvData = filteredEntries.map(entry => [
new Date(entry.date).toLocaleDateString('fr-FR'), new Date(entry.date).toLocaleDateString('fr-FR'),
entry.formateur || 'Non défini', entry.formateur || 'Non défini',
entry.campus || 'Non défini', entry.campus || 'Non défini',
entry.type === 'preparation' ? 'Préparation' : 'Correction',
entry.hours.toString(), entry.hours.toString(),
entry.heure_debut || 'Non défini', entry.heure_debut || 'Non défini',
entry.heure_fin || 'Non défini', entry.heure_fin || 'Non défini',
(entry.description || '').replace(/[\r\n]+/g, ' ').trim() (entry.description || '').replace(/[\r\n]+/g, ' ').trim()
]); ]);
// Fonction pour nettoyer les valeurs (enlever guillemets et point-virgules problématiques) const cleanCsvValue = (value: any) => {
const cleanCsvValue = (value) => {
return String(value || '') return String(value || '')
.replace(/"/g, '""') // Doubler les guillemets .replace(/"/g, '""')
.replace(/;/g, ','); // Remplacer ; par , dans les données .replace(/;/g, ',');
}; };
// Utiliser le point-virgule comme séparateur pour Excel français
const csvContent = '\uFEFF' + [csvHeaders, ...csvData] const csvContent = '\uFEFF' + [csvHeaders, ...csvData]
.map(row => row.map(cell => cleanCsvValue(cell)).join(';')) // Point-virgule ici ! .map(row => row.map(cell => cleanCsvValue(cell)).join(';'))
.join('\r\n'); // Utiliser \r\n pour Windows .join('\r\n');
const blob = new Blob([csvContent], { const blob = new Blob([csvContent], {
type: 'text/csv;charset=utf-8;' type: 'text/csv;charset=utf-8;'
@@ -563,17 +571,23 @@ const RHDashboard: React.FC = () => {
return ( return (
<div className="min-h-screen bg-gray-50 p-4"> <div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8 flex justify-between items-center"> <div className="mb-8 flex justify-between items-center">
<div> <div>
<h1 className="text-3xl font-bold text-gray-800 flex items-center gap-3"> <h1 className="text-3xl font-bold text-gray-800 flex items-center gap-3">
<Users className="text-blue-600" /> <Users className="text-blue-600" />
Vue RH - GTF Vue RH - GTF
</h1> </h1>
<p className="text-gray-600 mt-2"> <p className="text-gray-600 mt-2">
Gestion et suivi des déclarations des formateurs Gestion et suivi des déclarations des formateurs
</p> </p>
{user && (
<div className="mt-3 flex items-center gap-2 text-sm">
<div className="w-2 h-2 rounded-full bg-green-500"></div>
<span className="text-gray-700">
Connecté en tant que <span className="font-semibold">{user.nom} {user.prenom}</span>
</span>
</div>
)}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -596,7 +610,6 @@ const RHDashboard: React.FC = () => {
</div> </div>
</div> </div>
{/* Message d'erreur */}
{error && ( {error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6"> <div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -607,7 +620,6 @@ const RHDashboard: React.FC = () => {
</div> </div>
)} )}
{/* Indicateur de chargement */}
{(loading || loadingFormateurs) && ( {(loading || loadingFormateurs) && (
<div className="bg-white rounded-xl shadow-lg p-8 mb-6 text-center"> <div className="bg-white rounded-xl shadow-lg p-8 mb-6 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
@@ -619,13 +631,23 @@ const RHDashboard: React.FC = () => {
{!loading && !loadingFormateurs && ( {!loading && !loadingFormateurs && (
<> <>
{/* Filtres */}
<div className="bg-white rounded-xl shadow-lg p-6 mb-6"> <div className="bg-white rounded-xl shadow-lg p-6 mb-6">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<Filter className="text-gray-600" size={20} /> <Filter className="text-gray-600" size={20} />
<h2 className="text-lg font-semibold text-gray-800">Filtres</h2> <h2 className="text-lg font-semibold text-gray-800">Filtres</h2>
</div> </div>
<div className="mb-6">
<button
onClick={() => setShowClotureManager(!showClotureManager)}
className="flex items-center gap-2 px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors shadow-md"
>
<Lock size={20} />
{showClotureManager ? 'Masquer la gestion des clôtures' : 'Gérer les clôtures de saisie'}
</button>
</div>
{showClotureManager && <ClotureManager />}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
@@ -688,7 +710,6 @@ const RHDashboard: React.FC = () => {
</div> </div>
</div> </div>
{/* Statistiques par campus */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{campuses.map(campus => { {campuses.map(campus => {
const stats = getStatsForCampus(campus); const stats = getStatsForCampus(campus);
@@ -703,21 +724,12 @@ const RHDashboard: React.FC = () => {
<span className="text-sm text-gray-600">Total:</span> <span className="text-sm text-gray-600">Total:</span>
<span className="font-semibold text-gray-800">{stats.totalHours}h</span> <span className="font-semibold text-gray-800">{stats.totalHours}h</span>
</div> </div>
<div className="flex justify-between items-center">
<span className="text-sm text-blue-600">Préparation:</span>
<span className="font-medium text-blue-600">{stats.preparationHours}h</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-orange-600">Correction:</span>
<span className="font-medium text-orange-600">{stats.correctionHours}h</span>
</div>
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
{/* Résumé des résultats */}
<div className="bg-white rounded-xl shadow-lg p-6 mb-6"> <div className="bg-white rounded-xl shadow-lg p-6 mb-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -737,7 +749,6 @@ const RHDashboard: React.FC = () => {
</div> </div>
</div> </div>
{/* Tableau des déclarations */}
<div className="bg-white rounded-xl shadow-lg overflow-hidden"> <div className="bg-white rounded-xl shadow-lg overflow-hidden">
<div className="p-6 border-b border-gray-200"> <div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2"> <h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
@@ -750,30 +761,14 @@ const RHDashboard: React.FC = () => {
<table className="w-full"> <table className="w-full">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
Date <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Formateur</th>
</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Campus</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Heures</th>
Formateur <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Heure Début</th>
</th> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Heure Fin</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
Campus <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Heures
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Heure Début
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Heure Fin
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
@@ -798,21 +793,6 @@ const RHDashboard: React.FC = () => {
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{entry.campus} {entry.campus}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${entry.type === 'preparation'
? 'bg-blue-100 text-blue-800'
: 'bg-orange-100 text-orange-800'
}`}>
<div className="flex items-center gap-1">
{entry.type === 'preparation' ? (
<FileText size={12} />
) : (
<Clock size={12} />
)}
{entry.type === 'preparation' ? 'Préparation' : 'Correction'}
</div>
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
{entry.hours}h {entry.hours}h
</td> </td>
@@ -831,6 +811,24 @@ const RHDashboard: React.FC = () => {
<td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate"> <td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">
{entry.description} {entry.description}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(entry)}
className="text-blue-600 hover:text-blue-800 transition-colors"
title="Modifier"
>
<Edit2 size={18} />
</button>
<button
onClick={() => handleDelete(entry)}
className="text-red-600 hover:text-red-800 transition-colors"
title="Supprimer"
>
<Trash2 size={18} />
</button>
</div>
</td>
</tr> </tr>
)) ))
)} )}
@@ -838,6 +836,101 @@ const RHDashboard: React.FC = () => {
</table> </table>
</div> </div>
</div> </div>
{showEditModal && editingEntry && (
<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-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200 flex items-center justify-between sticky top-0 bg-white">
<h3 className="text-xl font-bold text-gray-800">Modifier la déclaration</h3>
<button onClick={() => setShowEditModal(false)} className="text-gray-400 hover:text-gray-600">
<X size={24} />
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Formateur</label>
<input
type="text"
value={editingEntry.formateur}
disabled
className="w-full border border-gray-300 rounded-lg px-4 py-2 bg-gray-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Date</label>
<input
type="date"
value={editForm.date}
onChange={(e) => setEditForm({ ...editForm, date: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Nombre d'heures</label>
<input
type="number"
step="0.5"
min="0"
value={editForm.hours}
onChange={(e) => setEditForm({ ...editForm, hours: parseFloat(e.target.value) || 0 })}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Heure début</label>
<input
type="time"
value={editForm.heure_debut}
onChange={(e) => setEditForm({ ...editForm, heure_debut: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Heure fin</label>
<input
type="time"
value={editForm.heure_fin}
onChange={(e) => setEditForm({ ...editForm, heure_fin: e.target.value })}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
<textarea
value={editForm.description}
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
rows={4}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
placeholder="Description de l'activité..."
/>
</div>
</div>
<div className="p-6 border-t border-gray-200 flex gap-3 justify-end">
<button
onClick={() => setShowEditModal(false)}
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
>
Annuler
</button>
<button
onClick={handleSaveEdit}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg flex items-center gap-2 transition-colors"
>
<Save size={18} />
Enregistrer
</button>
</div>
</div>
</div>
)}
</> </>
)} )}
</div> </div>

View File

@@ -26,7 +26,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
// Configuration Microsoft OAuth // Configuration Microsoft OAuth
const CLIENT_ID = 'cd99bbea-dcd4-4a76-a0b0-7aeb49931943'; const CLIENT_ID = 'cd99bbea-dcd4-4a76-a0b0-7aeb49931943';
const REDIRECT_URI = 'http://localhost:5174'; const REDIRECT_URI = 'https://mygtf-rh.ensup-adm.net';
const TENANT_ID = '9840a2a0-6ae1-4688-b03d-d2ec291be0f9'; const TENANT_ID = '9840a2a0-6ae1-4688-b03d-d2ec291be0f9';
// Vérifier l'état d'authentification au chargement // Vérifier l'état d'authentification au chargement
@@ -136,7 +136,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const userInfo = await userResponse.json(); const userInfo = await userResponse.json();
// Maintenant vérifier l'autorisation via votre backend // Maintenant vérifier l'autorisation via votre backend
const authResponse = await fetch('http://localhost:3002/api/check-user-groups', { const authResponse = await fetch('/api/check-user-groups', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -179,7 +179,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
try { try {
setLoading(true); setLoading(true);
const response = await fetch('http://localhost:3002/api/login-hybrid', { const response = await fetch('/api/login-hybrid', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -1,10 +1,19 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
optimizeDeps: { server: {
exclude: ['lucide-react'], host: true,
}, port: 3002,
}); strictPort: true,
allowedHosts: ['mygtf-rh.ensup-adm.net'],
proxy: {
'/api': {
target: 'http://backend:3000',
changeOrigin: true,
secure: false
}
}
}
})

View File

@@ -1,5 +1,5 @@
{ {
"dependencies": { "dependencies": {
"axios": "^1.12.2" "axios": "^1.7.0"
} }
} }