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

View File

@@ -5,7 +5,7 @@ const axios = require('axios');
require('dotenv').config();
const app = express();
const PORT = 3002;
const PORT = process.env.PORT || 8000;
// Configuration base de données
const dbConfig = {
@@ -23,16 +23,20 @@ const dbConfig = {
// Configuration Microsoft OAuth
const CLIENT_ID = 'cd99bbea-dcd4-4a76-a0b0-7aeb49931943';
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 GROUP_ID = 'c1ea877c-6bca-4f47-bfad-f223640813a0';
// Middleware
app.use(cors({
origin: ['http://localhost:5174', 'http://localhost:5173', 'http://localhost:3000'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
origin: [
'http://localhost:3001',
'http://localhost:8000',
'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' }));
@@ -85,23 +89,24 @@ async function checkSystemStatus() {
`);
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(`
SELECT COUNT(*) as count
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;
// 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) {
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;
console.log('✅ Accès à la vue Formateurs: OK (RH)');
console.log('✅ Accès à la vue v_Formateurs_CD: OK (RH)');
} catch (error) {
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(` - Colonne formateur_email_fk: ${systemStatus.hasFormateurEmailColumn ? '✅' : '❌'}`);
console.log(` - Vue Formateurs: ${systemStatus.hasFormateurView ? '✅' : '❌'}`);
console.log(` - Accès vue Formateurs: ${systemStatus.canAccessFormateurView ? '✅' : '❌'}`);
console.log(` - Vue v_Formateurs_CD: ${systemStatus.hasFormateurView ? '✅' : '❌'}`);
console.log(` - Accès vue v_Formateurs_CD: ${systemStatus.canAccessFormateurView ? '✅' : '❌'}`);
console.log(` - Table formateurs_local: ${systemStatus.hasFormateurLocal ? '✅' : '❌'}`);
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) => {
try {
// Vérifier les campus distincts dans la vue
const campusResult = await pool.request().query(`
SELECT DISTINCT Campus, COUNT(*) as nb_formateurs
FROM [dbo].[Formateurs]
FROM [GTF].[dbo].[v_Formateurs_CD]
GROUP BY Campus
ORDER BY Campus
`);
@@ -154,19 +179,18 @@ app.get('/api/debug-campus', async (req, res) => {
const sampleResult = await pool.request().query(`
SELECT TOP 10
userPrincipalName,
displayName,
Nom_brut,
Campus,
surname,
givenname
FROM [dbo].[Formateurs]
ORDER BY Campus, displayName
Contrat
FROM [GTF].[dbo].[v_Formateurs_CD]
ORDER BY Campus, Nom_brut
`);
res.json({
success: true,
campus_distincts: campusResult.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) {
@@ -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)
function generateHashFromEmail(email) {
let hash = 0;
@@ -449,11 +472,11 @@ app.get('/api/diagnostic', async (req, res) => {
break;
case 'new_with_local':
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;
case 'new_email_only':
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;
case 'legacy_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;
try {
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;
} else if (systemStatus.hasFormateurLocal) {
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)
app.get('/api/get_declarations', async (req, res) => {
try {
@@ -600,17 +753,15 @@ app.get('/api/get_declarations', async (req, res) => {
switch (systemStatus.operatingMode) {
case 'new_with_view':
// Avec vue Formateurs
// Avec vue v_Formateurs_CD
result = await pool.request().query(`
SELECT
d.id,
d.utilisateur_id,
d.formateur_email_fk as formateur_email,
f.displayName as formateur_nom_complet,
f.surname as nom,
f.givenname as prenom,
f.Nom_brut as formateur_nom_complet,
f.Campus,
f.departement,
f.Contrat,
td.id as type_demande_id,
td.libelle as activityType,
d.date,
@@ -621,7 +772,7 @@ app.get('/api/get_declarations', async (req, res) => {
d.status
FROM declarations d
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
`);
break;
@@ -702,8 +853,32 @@ app.get('/api/get_declarations', async (req, res) => {
// Traitement selon le mode
let processedResults = [];
if (systemStatus.operatingMode.startsWith('new_')) {
// Nouveau système - données déjà enrichies par les jointures
if (systemStatus.operatingMode === 'new_with_view') {
// 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 => ({
id: row.id,
utilisateur_id: row.utilisateur_id,
@@ -716,12 +891,30 @@ app.get('/api/get_declarations', async (req, res) => {
heure_fin: formatSqlTime(row.heure_fin),
status: row.status || 'pending',
activityType: row.activityType,
// Informations formateur (peuvent être null si pas de jointure)
nom: row.nom || (row.formateur_email ? row.formateur_email.split('@')[0] : 'Inconnu'),
prenom: row.prenom || '',
campus: row.Campus || 'Non défini',
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 {
// Ancien système - mapping manuel
const knownMappings = {
@@ -799,7 +992,7 @@ app.get('/api/debug-hash', (req, res) => {
if (!email) {
return res.json({
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) => {
try {
let result;
@@ -820,56 +1013,80 @@ app.get('/api/formateurs-avec-declarations', async (req, res) => {
switch (systemStatus.operatingMode) {
case 'new_with_view':
result = await pool.request().query(`
SELECT DISTINCT
f.givenname,
f.surname,
f.displayName,
f.Campus,
f.userPrincipalName,
f.Jobtitle,
f.Contrat,
COUNT(d.id) as nb_declarations
FROM [dbo].[Formateurs] f
LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk
WHERE (f.Contrat = 'CDD' OR f.Contrat LIKE '%CDD%')
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
`);
SELECT DISTINCT
f.Nom_brut,
f.Campus,
f.userPrincipalName,
f.Contrat,
COUNT(d.id) as nb_declarations
FROM [GTF].[dbo].[v_Formateurs_CD] f
LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk
WHERE LOWER(f.Contrat) LIKE 'cd%'
AND d.id IS NOT NULL
GROUP BY f.Nom_brut, f.Campus, f.userPrincipalName, f.Contrat
ORDER BY f.Nom_brut
`);
break;
case 'new_with_local':
result = await pool.request().query(`
SELECT DISTINCT
f.givenname,
f.surname,
f.displayName,
f.Campus,
f.userPrincipalName,
f.Jobtitle,
f.Contrat,
COUNT(d.id) as nb_declarations
FROM formateurs_local f
LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk
WHERE (f.Contrat = 'CDD' OR f.Contrat LIKE '%CDD%')
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
`);
SELECT DISTINCT
f.givenname,
f.surname,
f.displayName,
f.Campus,
f.userPrincipalName,
f.Jobtitle,
f.Contrat,
COUNT(d.id) as nb_declarations
FROM formateurs_local f
LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk
WHERE LOWER(f.Contrat) LIKE 'cd%'
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;
default:
return res.json({
success: false,
message: 'Cette fonctionnalité nécessite le nouveau système',
mode: systemStatus.operatingMode
});
}
const 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()
}));
let formateurs;
if (systemStatus.operatingMode === 'new_with_view') {
// Parser le Nom_brut pour v_Formateurs_CD
formateurs = result.recordset.map(f => {
const { nom, prenom } = parseNomBrut(f.Nom_brut);
return {
userPrincipalName: f.userPrincipalName,
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({
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})...`);
let formateurs = [];
let result; // Déclarer result ici !
let result;
if (systemStatus.canAccessFormateurView) {
console.log('Utilisation de la vue Formateurs...');
console.log('Utilisation de la vue v_Formateurs_CD...');
result = await pool.request().query(`
SELECT
userPrincipalName,
displayName,
surname,
givenname,
Nom_brut,
Campus,
Contrat
FROM [dbo].[Formateurs]
WHERE Contrat = 'CDD' OR Contrat LIKE '%CDD%'
ORDER BY surname, givenname
FROM [GTF].[dbo].[v_Formateurs_CD]
WHERE LOWER(Contrat) LIKE 'cd%'
ORDER BY Nom_brut
`);
} else if (systemStatus.hasFormateurLocal) {
console.log('Utilisation de formateurs_local...');
@@ -920,7 +1136,8 @@ app.get('/api/formateurs-vue', async (req, res) => {
Campus,
Contrat
FROM formateurs_local
WHERE Contrat = 'CDD' OR Contrat LIKE '%CDD%'
WHERE LOWER(Contrat) LIKE 'cd%'
ORDER BY surname, givenname
`);
} else {
@@ -939,9 +1156,18 @@ app.get('/api/formateurs-vue', async (req, res) => {
if (result.recordset.length === 0) {
console.log('Aucun formateur CDD trouvé, test sans filtre...');
// Test sans le filtre CDD pour voir s'il y a des formateurs
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(`
SELECT TOP 5
userPrincipalName,
@@ -953,20 +1179,41 @@ app.get('/api/formateurs-vue', async (req, res) => {
FROM formateurs_local
ORDER BY surname, givenname
`);
}
if (testResult) {
console.log('Test sans filtre:', testResult.recordset);
console.log('Types de contrats:', [...new Set(testResult.recordset.map(f => f.Contrat))]);
}
}
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()
}));
if (systemStatus.canAccessFormateurView) {
// Parser Nom_brut pour v_Formateurs_CD
formateurs = result.recordset.map(f => {
const { nom, prenom } = parseNomBrut(f.Nom_brut);
return {
userPrincipalName: f.userPrincipalName,
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`);
@@ -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) => {
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) {
try {
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) => {
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) => {
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) => {
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(`
SELECT TOP 3
userPrincipalName,
displayName,
Campus
FROM [HP-TO-O365].[dbo].[V_Formateurs_Augmentees]
Nom_brut,
Campus,
Contrat
FROM [GTF].[dbo].[v_Formateurs_CD]
`);
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) => {
try {
if (!pool) {
@@ -1660,9 +1895,117 @@ app.get('/api/rh-test', async (req, res) => {
}
});
/**
* Route pour lister tous les utilisateurs de la table RH
*/
// Routes de clôture
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) => {
try {
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('');
console.log('Routes disponibles :');
console.log('- GET /api/diagnostic (vérifier l\'état)');
console.log('- POST /api/migrate (appliquer la migration)');
console.log('- GET /api/diagnostic');
console.log('- POST /api/migrate');
console.log('- GET /api/test');
console.log('- GET /api/db-test');
console.log('- GET /api/get_declarations');
console.log('- PUT /api/declarations/:id/status');
console.log('- POST /api/exchange-token (Échange code Microsoft)');
console.log('- POST /api/auth (Microsoft Graph - legacy)');
console.log('- GET /api/admin-users (3 utilisateurs administratifs)');
console.log('- GET /api/group-members (tous les membres du groupe)');
console.log('- GET /api/formateurs-avec-declarations');
console.log('- DELETE /api/declarations/:id');
console.log('- PUT /api/declarations/:id');
console.log('- POST /api/exchange-token');
console.log('- GET /api/formateurs-vue');
console.log('- GET /api/rh-test (test table RH)');
console.log('- GET /api/rh-users (liste utilisateurs RH)');
console.log('- GET /api/clotures');
console.log('- POST /api/cloturer-periode');
console.log('- DELETE /api/rouvrir-periode/:id');
console.log('');
switch (systemStatus.operatingMode) {
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;
case 'new_with_local':
console.log('⚠️ Mode dégradé - utilise la table formateurs_local');
console.log('💡 Conseil: Vérifier les permissions sur HP-TO-O365');
break;
case 'new_email_only':
console.log('⚠️ Mode minimal - sauvegarde par email sans détails formateurs');
break;
case 'legacy_hash':
console.log('🔄 Mode compatibilité - utilise l\'ancien système de hash');
console.log('💡 Conseil: Appliquer la migration avec POST /api/migrate');
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 () => {
console.log('Arrêt du serveur RH...');
if (pool) {
@@ -1764,5 +2093,4 @@ process.on('SIGINT', async () => {
process.exit(0);
});
// Démarrer
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: {
clientId: "cd99bbea-dcd4-4a76-a0b0-7aeb49931943", // Application (client) ID dans Azure
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: {
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 { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import { AlertTriangle, Users } from 'lucide-react';
import React, { useState } from "react";
import { useAuth } from "../context/AuthContext";
import { useNavigate } from "react-router-dom";
import { AlertTriangle } from "lucide-react";
const Login = () => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState("");
const navigate = useNavigate();
const { loginWithO365, isAuthorized } = 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 { loginWithO365 } = useAuth();
const handleO365Login = async () => {
setIsLoading(true);
setError('');
setError("");
try {
// 1. Connexion Office 365 via votre contexte existant
const success = await loginWithO365();
if (!success) {
setError("Erreur lors de l'initialisation de la connexion Office 365");
setIsLoading(false);
return;
setError("Erreur lors de la connexion Office 365");
} else {
navigate("/dashboard");
}
// Le reste sera géré par l'AuthContext lors du retour de Microsoft
// (exchangeCodeForToken + vérification backend)
} catch (err: any) {
console.error('Erreur O365:', err);
setError(err.message || "Erreur lors de la connexion Office 365");
} finally {
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 (
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div className="max-w-md w-full">
<div className="bg-white rounded-lg shadow-md p-8">
<div className="min-h-screen flex">
{/* Colonne gauche avec image + overlay */}
<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="w-16 h-16 bg-[#7e5aa2] rounded-lg flex items-center justify-center mx-auto mb-4">
<Users className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">GTF - Espace RH</h1>
<p className="text-gray-600 text-sm">
Connectez-vous avec votre compte Office 365
<h2 className="text-2xl font-bold text-gray-900">
Connexion à lespace RH
</h2>
<p className="text-gray-600 mt-2">
Utilisez votre compte Office 365
</p>
</div>
{/* Bouton de connexion Office 365 */}
{/* Bouton violet ENSUP */}
<button
onClick={handleO365Login}
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 ? (
<>
<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...
</>
) : (
<>
<span>Se connecter avec Office 365</span>
</>
<>Se connecter avec Office 365</>
)}
</button>
{/* Message d'erreur */}
{error && (
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start space-x-2">
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-red-800 font-medium text-sm">
{error.includes('Accès refusé') ? 'Accès refusé' :
error.includes('serveur') ? 'Problème de connexion' :
'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 className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2">
<AlertTriangle className="w-5 h-5 text-red-500 mt-0.5" />
<div>
<p className="text-red-800 font-medium text-sm">
Erreur de connexion
</p>
<p className="text-red-700 text-xs mt-1">{error}</p>
</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>

View File

@@ -1,20 +1,22 @@
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 ClotureManager from '../components/CloturePeriode';
interface TimeEntry {
id: string;
formateur: string;
campus: string;
date: string;
type: 'preparation' | 'correction';
type: 'preparation';
hours: number;
description: string;
status: 'pending' | 'approved' | 'rejected';
heure_debut?: string;
heure_fin?: string;
formateur_numero?: number;
formateur_email?: string; // NOUVEAU CHAMP
formateur_email?: string;
type_demande_id?: number;
}
interface FormateurAvecDeclarations {
@@ -42,7 +44,8 @@ const RHDashboard: React.FC = () => {
const month = String(now.getMonth() + 1).padStart(2, '0');
return `${year}-${month}`;
});
const { logout } = useAuth();
const [showClotureManager, setShowClotureManager] = useState(false);
const { logout, user } = useAuth();
const [selectedFormateur, setSelectedFormateur] = useState<string>('all');
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
const [formateursAvecDeclarations, setFormateursAvecDeclarations] = useState<FormateurAvecDeclarations[]>([]);
@@ -51,54 +54,34 @@ const RHDashboard: React.FC = () => {
const [error, setError] = useState<string>('');
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 mapping: { [key: string]: string } = {
// Codes courts vers noms longs
'MRS': 'Marseille',
'mrs': 'Marseille',
'NTE': 'Nantes',
'nte': 'Nantes',
'CGY': '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',
'MRS': 'Marseille', 'mrs': 'Marseille',
'NTE': 'Nantes', 'nte': 'Nantes',
'CGY': 'Cergy', 'cgy': 'Cergy',
'SQY': 'SQY', 'sqy': 'SQY', 'ensqy': 'SQY',
'SQY/CGY': 'SQY/CGY', 'sqy/cgy': 'SQY/CGY',
'Marseille': 'Marseille', 'Nantes': 'Nantes', 'Cergy': 'Cergy',
'Saint-Quentin-en-Yvelines': 'SQY',
'MARSEILLE': 'Marseille',
'NANTES': 'Nantes',
'CERGY': 'Cergy',
// Fallback
'Non défini': 'Non défini',
'': 'Non défini'
'MARSEILLE': 'Marseille', 'NANTES': 'Nantes', 'CERGY': 'Cergy',
'Non défini': 'Non défini', '': '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'];
// Fonction pour générer le hash (même logique que le backend - pour compatibilité)
const generateHashFromEmail = (email: string): number => {
let hash = 0;
for (let i = 0; i < email.length; i++) {
@@ -109,10 +92,9 @@ const RHDashboard: React.FC = () => {
return Math.abs(hash) % 10000 + 1000;
};
// Fonction pour récupérer le statut du système
const loadSystemStatus = async () => {
try {
const response = await fetch('http://localhost:3002/api/diagnostic');
const response = await fetch('/api/diagnostic');
if (response.ok) {
const data = await response.json();
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 } => {
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) {
const email = declaration.formateur_email || declaration.formateur_email_fk;
const formateurTrouve = formateursAvecDeclarations.find(f =>
@@ -139,13 +119,12 @@ const RHDashboard: React.FC = () => {
return {
nom: formateurTrouve.nom,
prenom: formateurTrouve.prenom,
campus: formateurTrouve.campus, // Garder le campus original de la vue
campus: formateurTrouve.campus,
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') {
console.log(`✅ Formateur trouvé dans les données déclaration: ${declaration.nom} ${declaration.prenom} (${declaration.campus})`);
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) {
console.log(`🔍 Recherche par hash pour numéro: ${declaration.formateur_numero}`);
// Méthode 1: Chercher par hash d'email
const formateurParHash = formateursAvecDeclarations.find(f => {
if (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 } } = {
122: { nom: 'Admin', prenom: 'Ensup', campus: 'SQY' },
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';
console.log(`⚠️ Aucune correspondance trouvée, utilisation du fallback pour: ${identifier}`);
return {
@@ -208,16 +182,13 @@ const RHDashboard: React.FC = () => {
};
};
// Fonction pour charger TOUS les formateurs (avec et sans déclarations)
const loadFormateursAvecDeclarations = async () => {
try {
setLoadingFormateurs(true);
setError('');
console.log('🔄 Chargement de tous les formateurs...');
// Essayer d'abord de récupérer tous les formateurs depuis la vue
const response = await fetch('http://localhost:3002/api/formateurs-vue');
const response = await fetch('/api/formateurs-vue');
if (response.ok) {
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('📊 Mode serveur:', data.mode);
// Convertir le format et initialiser à 0 déclarations
const tousLesFormateurs = data.formateurs.map((f: any) => ({
...f,
nbDeclarations: 0,
@@ -242,16 +212,15 @@ const RHDashboard: React.FC = () => {
} catch (error: any) {
console.error('❌ Erreur chargement formateurs depuis la vue:', error);
// Fallback: essayer avec formateurs-avec-declarations (ancienne méthode)
try {
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) {
const fallbackData = await fallbackResponse.json();
if (fallbackData.success) {
setFormateursAvecDeclarations(fallbackData.formateurs);
console.log('🔄 Fallback réussi:', fallbackData.formateurs.length, 'formateurs avec déclarations');
setError(''); // Effacer l'erreur si le fallback fonctionne
setError('');
}
}
} catch (fallbackError) {
@@ -263,14 +232,13 @@ const RHDashboard: React.FC = () => {
}
};
// Charger les déclarations depuis votre API
const loadDeclarations = async () => {
try {
setLoading(true);
setError('');
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) {
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('📊 Exemple de déclaration:', data[0]);
// Convertir les données (avec normalisation des campus)
const convertedEntries: TimeEntry[] = data.map((d: any) => {
const formateurInfo = getFormateurInfo(d);
return {
id: d.id.toString(),
formateur: formateurInfo.displayText,
campus: normalizeCampus(formateurInfo.campus), // Normaliser le campus
campus: normalizeCampus(formateurInfo.campus),
date: d.date.split('T')[0],
type: d.activityType,
type: 'preparation',
hours: d.duree,
description: d.description || '',
status: d.status || 'pending',
heure_debut: d.heure_debut || null,
heure_fin: d.heure_fin || null,
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);
console.log(`${convertedEntries.length} déclarations traitées`);
// Log des formateurs uniques pour debug
const formateursUniques = [...new Set(convertedEntries.map(e => e.formateur))];
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(() => {
const loadData = async () => {
await loadSystemStatus();
await loadFormateursAvecDeclarations();
await loadDeclarations();
// Debug: Afficher tous les campus uniques trouvés dans les données
setTimeout(() => {
const campusUniques = [...new Set(formateursAvecDeclarations.map(f => f.campus))].filter(Boolean);
const campusDeclarations = [...new Set(timeEntries.map(e => e.campus))].filter(Boolean);
@@ -335,12 +300,10 @@ const RHDashboard: React.FC = () => {
loadData();
}, []);
// Re-traiter les déclarations quand les formateurs sont chargés ET calculer les nombres de déclarations
useEffect(() => {
if (formateursAvecDeclarations.length > 0) {
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) {
const updatedEntries = timeEntries.map(entry => {
const originalDeclaration = {
@@ -359,21 +322,17 @@ const RHDashboard: React.FC = () => {
setTimeEntries(updatedEntries);
}
// Calculer le nombre de déclarations pour chaque formateur
const formateursAvecCompte = formateursAvecDeclarations.map(formateur => {
const nbDeclarations = timeEntries.filter(entry => {
// Correspondance par email
if (entry.formateur_email === formateur.userPrincipalName) {
return true;
}
// Correspondance par hash
if (entry.formateur_numero && formateur.userPrincipalName) {
const hash = generateHashFromEmail(formateur.userPrincipalName);
return hash === entry.formateur_numero;
}
// Correspondance par nom affiché
const expectedDisplayText = `${formateur.nom} ${formateur.prenom} (${formateur.campus})`.trim();
return entry.formateur === expectedDisplayText || entry.formateur === formateur.displayText;
}).length;
@@ -386,19 +345,15 @@ const RHDashboard: React.FC = () => {
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(() => {
// Si un formateur est sélectionné et qu'on change de campus
if (selectedFormateur !== 'all') {
// Vérifier si le formateur sélectionné existe encore dans le campus filtré
const formateurExists = formateursAvecDeclarations.some(formateur => {
const campusMatch = selectedCampus === 'all' || formateur.campus === selectedCampus;
return campusMatch && formateur.displayText === selectedFormateur;
});
// Si le formateur n'existe pas dans le nouveau campus, le réinitialiser
if (!formateurExists) {
console.log(`🔄 Réinitialisation du filtre formateur (${selectedFormateur} n'est pas dans ${selectedCampus})`);
setSelectedFormateur('all');
@@ -406,7 +361,6 @@ const RHDashboard: React.FC = () => {
}
}, [selectedCampus, formateursAvecDeclarations]);
// Fonction de déconnexion
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('o365_token');
@@ -415,14 +369,83 @@ const RHDashboard: React.FC = () => {
window.location.href = '/login';
};
// Fonction de rafraîchissement complète
const handleRefresh = async () => {
await loadSystemStatus();
await loadFormateursAvecDeclarations();
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 entryNormalizedCampus = normalizeCampus(entry.campus);
const campusMatch = selectedCampus === 'all' || entryNormalizedCampus === selectedCampus;
@@ -431,75 +454,60 @@ const RHDashboard: React.FC = () => {
return campusMatch && formateurMatch && monthMatch;
});
// Générer la liste des formateurs filtrés par campus (avec déduplication)
const formateursUniques = formateursAvecDeclarations
.filter(formateur => {
// Si "tous les campus" est sélectionné, afficher tous les formateurs
if (selectedCampus === 'all') return true;
// Normaliser le campus du formateur et comparer avec le campus sélectionné
const formateurCampusNormalized = normalizeCampus(formateur.campus);
return formateurCampusNormalized === selectedCampus;
})
.reduce((acc: any[], formateur) => {
// Déduplicquer par email (userPrincipalName)
const existing = acc.find(f => f.userPrincipalName === formateur.userPrincipalName);
if (!existing) {
acc.push({
displayText: formateur.displayText,
nbDeclarations: formateur.nbDeclarations || 0,
userPrincipalName: formateur.userPrincipalName,
campus: normalizeCampus(formateur.campus) // Normaliser le campus pour l'affichage
campus: normalizeCampus(formateur.campus)
});
} else {
// Si le formateur existe déjà, additionner les déclarations
existing.nbDeclarations += (formateur.nbDeclarations || 0);
}
return acc;
}, [])
.sort((a, b) => a.displayText.localeCompare(b.displayText));
// Statistiques par campus (avec normalisation)
const getStatsForCampus = (campus: string) => {
const campusEntries = timeEntries.filter(entry => {
const entryNormalizedCampus = normalizeCampus(entry.campus);
return entryNormalizedCampus === campus;
});
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 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 => [
new Date(entry.date).toLocaleDateString('fr-FR'),
entry.formateur || 'Non défini',
entry.campus || 'Non défini',
entry.type === 'preparation' ? 'Préparation' : 'Correction',
entry.hours.toString(),
entry.heure_debut || 'Non défini',
entry.heure_fin || 'Non défini',
(entry.description || '').replace(/[\r\n]+/g, ' ').trim()
]);
// Fonction pour nettoyer les valeurs (enlever guillemets et point-virgules problématiques)
const cleanCsvValue = (value) => {
const cleanCsvValue = (value: any) => {
return String(value || '')
.replace(/"/g, '""') // Doubler les guillemets
.replace(/;/g, ','); // Remplacer ; par , dans les données
.replace(/"/g, '""')
.replace(/;/g, ',');
};
// Utiliser le point-virgule comme séparateur pour Excel français
const csvContent = '\uFEFF' + [csvHeaders, ...csvData]
.map(row => row.map(cell => cleanCsvValue(cell)).join(';')) // Point-virgule ici !
.join('\r\n'); // Utiliser \r\n pour Windows
.map(row => row.map(cell => cleanCsvValue(cell)).join(';'))
.join('\r\n');
const blob = new Blob([csvContent], {
type: 'text/csv;charset=utf-8;'
@@ -563,17 +571,23 @@ const RHDashboard: React.FC = () => {
return (
<div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-800 flex items-center gap-3">
<Users className="text-blue-600" />
Vue RH - GTF
</h1>
<p className="text-gray-600 mt-2">
Gestion et suivi des déclarations des formateurs
</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 className="flex items-center gap-3">
@@ -596,7 +610,6 @@ const RHDashboard: React.FC = () => {
</div>
</div>
{/* Message d'erreur */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
<div className="flex items-center gap-2">
@@ -607,7 +620,6 @@ const RHDashboard: React.FC = () => {
</div>
)}
{/* Indicateur de chargement */}
{(loading || loadingFormateurs) && (
<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>
@@ -619,13 +631,23 @@ const RHDashboard: React.FC = () => {
{!loading && !loadingFormateurs && (
<>
{/* Filtres */}
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
<div className="flex items-center gap-2 mb-4">
<Filter className="text-gray-600" size={20} />
<h2 className="text-lg font-semibold text-gray-800">Filtres</h2>
</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>
<label className="block text-sm font-medium text-gray-700 mb-2">
@@ -688,7 +710,6 @@ const RHDashboard: React.FC = () => {
</div>
</div>
{/* Statistiques par campus */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{campuses.map(campus => {
const stats = getStatsForCampus(campus);
@@ -703,21 +724,12 @@ const RHDashboard: React.FC = () => {
<span className="text-sm text-gray-600">Total:</span>
<span className="font-semibold text-gray-800">{stats.totalHours}h</span>
</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>
{/* Résumé des résultats */}
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
<div className="flex items-center justify-between">
<div>
@@ -737,7 +749,6 @@ const RHDashboard: React.FC = () => {
</div>
</div>
{/* Tableau des déclarations */}
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
<div className="p-6 border-b border-gray-200">
<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">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Formateur
</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">
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>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Formateur</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">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>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<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">
{entry.campus}
</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">
{entry.hours}h
</td>
@@ -831,6 +811,24 @@ const RHDashboard: React.FC = () => {
<td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">
{entry.description}
</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>
))
)}
@@ -838,6 +836,101 @@ const RHDashboard: React.FC = () => {
</table>
</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>

View File

@@ -26,7 +26,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
// Configuration Microsoft OAuth
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';
// Vérifier l'état d'authentification au chargement
@@ -136,7 +136,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const userInfo = await userResponse.json();
// 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',
headers: {
'Content-Type': 'application/json',
@@ -179,7 +179,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
try {
setLoading(true);
const response = await fetch('http://localhost:3002/api/login-hybrid', {
const response = await fetch('/api/login-hybrid', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

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